) | undefined;
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ /**
+ * End-to-end test that verifies the full parameter flow:
+ * 1. Create a parameter-select widget that populates a dropdown
+ * 2. Create a dependent table widget whose query references $param_year
+ * 3. Save the dashboard, switch to view mode
+ * 4. Select a year value from the parameter dropdown
+ * 5. Verify the dependent widget doesn't show a query error
+ */
+ test("selecting a parameter value should refresh dependent widgets", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+
+ // Create a fresh dashboard for this test via API
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Param Cycle ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+
+ // --- Widget 1: Parameter-select (year dropdown) ---
+ // Use .first() because a fresh dashboard shows both a toolbar and an empty-state "Add Widget" button
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ // Scope dialog to the "Add Widget" modal by name to avoid matching Radix popovers
+ // that also have role="dialog" and can cause strict mode violations
+ let dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select "Parameter Selector" chart type
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Parameter Selector" }).click();
+ // Select Neo4j connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Configure query for distinct years via the seed-query textarea
+ // (CodeMirror is hidden for parameter-select widgets; SeedQueryInput uses a textarea)
+ await dialog
+ .locator("#seed-query")
+ .fill(
+ "MATCH (m:Movie) RETURN DISTINCT m.released ORDER BY m.released LIMIT 10",
+ );
+
+ // Set parameter name
+ const paramNameInput = dialog.getByLabel("Parameter Name");
+ await paramNameInput.fill("year");
+
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog).not.toBeVisible();
+
+ // --- Widget 2: Dependent table using $param_year ---
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select "Data Table" chart type
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+ // Select connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) WHERE m.released = toInteger($param_year) RETURN m.title AS title LIMIT 5",
+ );
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog).not.toBeVisible();
+
+ // Save the dashboard
+ await page.getByRole("button", { name: "Save" }).click();
+ // Wait for save to complete (button text changes while saving)
+ await expect(page.getByRole("button", { name: "Save" })).toBeEnabled({
+ timeout: 10_000,
+ });
+
+ // Navigate to view mode via the "Back" button
+ await page.getByRole("button", { name: "Back" }).click();
+ // Handle unsaved-changes dialog if it appears (grid compaction race)
+ const leaveBtn = page.getByRole("button", { name: "Leave" });
+ if (await leaveBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
+ await leaveBtn.click();
+ }
+ await expect(page).not.toHaveURL(/\/edit$/, { timeout: 10_000 });
+
+ // The parameter-select widget should render a dropdown with "year" label
+ await expect(page.getByText("year", { exact: true })).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Select a value from the parameter dropdown.
+ // The ParamSelector placeholder uses Unicode ellipsis "…" (U+2026), not three periods
+ const paramTrigger = page.getByText("Select a value…");
+ await paramTrigger.click();
+ // Pick the first available year option from the Radix select dropdown.
+ // Use toPass() to handle Radix animation instability (element may be
+ // detached/re-mounted during the opening animation).
+ await expect(async () => {
+ await page.getByRole("option").first().click({ timeout: 2_000 });
+ }).toPass({ timeout: 15_000 });
+
+ // After selecting a parameter, the dependent table widget should refresh
+ // and should NOT show a "Query Failed" error (it had one before selection
+ // because $param_year was not yet set).
+ // Wait for query re-execution by checking that no error appears.
+ await expect(page.locator("text=Query Failed")).not.toBeVisible({
+ timeout: 10_000,
+ });
+ });
+});
+
+test.describe("Click actions", () => {
+ /**
+ * Helper: create a dashboard with click-action widgets via the API.
+ * Returns the dashboard ID and a cleanup function.
+ */
+ async function createClickActionDashboard(
+ request: import("@playwright/test").APIRequestContext,
+ ) {
+ const res = await request.post("/api/dashboards", {
+ data: { name: `Click Actions ${Date.now()}` },
+ });
+ if (!res.ok()) throw new Error(`Create dashboard failed: ${res.status()}`);
+ const { id } = (await res.json()).data;
+
+ const layout = {
+ version: 2 as const,
+ pages: [
+ {
+ id: "page-cell-click",
+ title: "Cell Click",
+ widgets: [
+ {
+ id: "ca-w1",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query:
+ "MATCH (m:Movie) RETURN m.title AS title, m.released AS released ORDER BY m.title LIMIT 20",
+ settings: {
+ title: "Movies",
+ clickAction: {
+ type: "set-parameter",
+ parameterMapping: {
+ parameterName: "param_clicked_movie",
+ sourceField: "",
+ },
+ },
+ },
+ },
+ {
+ id: "ca-w2",
+ chartType: "bar",
+ connectionId: "conn-neo4j-001",
+ query:
+ "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WHERE m.title = $param_clicked_movie RETURN p.name AS name, 1 AS count",
+ settings: { title: "Cast" },
+ },
+ ],
+ gridLayout: [
+ { i: "ca-w1", x: 0, y: 0, w: 6, h: 5 },
+ { i: "ca-w2", x: 6, y: 0, w: 6, h: 5 },
+ ],
+ },
+ {
+ id: "page-navigate",
+ title: "Navigate to Page",
+ widgets: [
+ {
+ id: "ca-w3",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query:
+ "MATCH (m:Movie) RETURN m.title AS title, m.released AS released ORDER BY m.title LIMIT 20",
+ settings: {
+ title: "Click to navigate",
+ clickAction: {
+ type: "set-parameter-and-navigate",
+ parameterMapping: {
+ parameterName: "param_clicked_movie",
+ sourceField: "",
+ },
+ targetPageId: "page-cell-click",
+ },
+ },
+ },
+ ],
+ gridLayout: [{ i: "ca-w3", x: 0, y: 0, w: 12, h: 5 }],
+ },
+ ],
+ };
+
+ const putRes = await request.put(`/api/dashboards/${id}`, {
+ data: { layoutJson: layout },
+ });
+ if (!putRes.ok())
+ throw new Error(`Update dashboard failed: ${putRes.status()}`);
+
+ return {
+ id,
+ cleanup: async () => {
+ await request.delete(`/api/dashboards/${id}`);
+ },
+ };
+ }
+
+ test("cell-click on table sets a parameter and updates dependent widgets", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createClickActionDashboard(page.request);
+
+ try {
+ await page.goto(`/${id}`);
+
+ // Wait for the table with movie titles to render.
+ // Use "Apollo 13" — it's 3rd alphabetically, guaranteed on page 1
+ // of the DataGrid (ORDER BY m.title LIMIT 20 returns first 20, paged at 10).
+ const movieCell = page.locator("td").filter({ hasText: "Apollo 13" });
+ await expect(movieCell.first()).toBeVisible({ timeout: 15_000 });
+
+ // Click a cell in the movies table
+ await movieCell.first().click();
+
+ // The parameter bar should appear with a cross-filter tag
+ await expect(page.getByText("Reset")).toBeVisible({ timeout: 5_000 });
+
+ // The dependent widgets should re-run without errors
+ await expect(page.locator("text=Query Failed")).not.toBeVisible({
+ timeout: 10_000,
+ });
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("click action with navigate-to-page switches to target page", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createClickActionDashboard(page.request);
+
+ try {
+ await page.goto(`/${id}`);
+
+ // Wait for the dashboard to load and render page tabs
+ await expect(
+ page.getByRole("tab", { name: "Navigate to Page" }),
+ ).toBeVisible({ timeout: 15_000 });
+
+ // Navigate to the "Navigate to Page" tab
+ await page.getByRole("tab", { name: "Navigate to Page" }).click();
+
+ // Wait for the table on the ACTIVE page to load.
+ // Both pages have "Apollo 13" in their tables, but page 1 is now hidden
+ // (className="hidden", aria-hidden="true"). Scope to the visible container
+ // so we don't accidentally pick page 1's hidden .
+ const activePage = page.locator('div[aria-hidden="false"]');
+ const movieCell = activePage
+ .locator("td")
+ .filter({ hasText: "Apollo 13" });
+ await expect(movieCell.first()).toBeVisible({ timeout: 15_000 });
+
+ // Click a movie title cell — should navigate to page 1 and set the parameter
+ await movieCell.first().click();
+
+ // After navigation, "Cell Click" tab should be active
+ await expect(
+ page.getByRole("tab", { name: "Cell Click" }),
+ ).toHaveAttribute("data-state", "active", { timeout: 5_000 });
+
+ // The parameter bar should show the clicked value.
+ // Both pages may render parameter bars, so use .first() to avoid
+ // strict mode violation from matching 2 "Reset" buttons.
+ await expect(page.getByText("Reset").first()).toBeVisible({
+ timeout: 5_000,
+ });
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("widget editor shows manage action rules button when click action enabled", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Click UI ${Date.now()}`,
+ );
+
+ try {
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select "Data Table" chart type
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+ // Select connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Navigate to Advanced tab and enable click action
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await dialog.getByLabel("Enable click action").click();
+
+ // Should show "Manage Action Rules" button
+ await expect(
+ dialog.getByRole("button", { name: "Manage Action Rules" }),
+ ).toBeVisible();
+ // Should show "No action rules configured." text
+ await expect(
+ dialog.getByText("No action rules configured."),
+ ).toBeVisible();
+
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("action rules editor opens and allows adding rules", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Action Rules ${Date.now()}`,
+ );
+
+ try {
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible({ timeout: 15_000 });
+
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select Bar chart + connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Run a query to get available fields
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) RETURN m.title AS movie, count(p) AS cast_size LIMIT 5",
+ );
+ await expect(
+ dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)"),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)").click();
+ await expect(getPreview(dialog)).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Navigate to Advanced tab and enable click action
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await dialog.getByLabel("Enable click action").click();
+
+ // Click "Manage Action Rules" to open the rules editor
+ await dialog.getByRole("button", { name: "Manage Action Rules" }).click();
+ const rulesDialog = page.getByRole("dialog", { name: "Action Rules" });
+
+ // Should show the Action Rules heading
+ await expect(
+ rulesDialog.getByRole("heading", { name: "Action Rules" }),
+ ).toBeVisible();
+ // Should show "No action rules yet" message
+ await expect(rulesDialog.getByText("No action rules yet")).toBeVisible();
+
+ // Click "Add Rule"
+ await rulesDialog.getByRole("button", { name: "Add Rule" }).click();
+ // Should show "Rule 1"
+ await expect(rulesDialog.getByText("Rule 1")).toBeVisible();
+ // Should show Action Type selector
+ await expect(rulesDialog.getByText("Action Type")).toBeVisible({
+ timeout: 5_000,
+ });
+ // Should show Parameter Name
+ await expect(rulesDialog.getByText("Parameter Name")).toBeVisible();
+ // Should show Source Field (for bar chart, not table)
+ await expect(rulesDialog.getByText("Source Field")).toBeVisible();
+
+ // Click "Done" to return to main dialog
+ await rulesDialog.getByRole("button", { name: "Done" }).click();
+ // Navigate back to Advanced tab (Done returns to Data tab)
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ // Should show "1 action rule(s) configured."
+ await expect(
+ dialog.getByText("1 action rule(s) configured."),
+ ).toBeVisible();
+
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("widget editor hides click action for unsupported chart types", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Unsupported Click ${Date.now()}`,
+ );
+
+ try {
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select connection first
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Select "Single Value" chart type
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Single Value" }).click();
+
+ // Navigate to Advanced tab — click action should NOT be visible
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await expect(dialog.getByLabel("Enable click action")).not.toBeVisible();
+
+ // Switch to "JSON Viewer" — click action should also NOT be visible
+ await dialog.getByRole("tab", { name: "Data" }).click();
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "JSON Viewer" }).click();
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await expect(dialog.getByLabel("Enable click action")).not.toBeVisible();
+
+ // Switch to "Bar Chart" — click action should be visible
+ await dialog.getByRole("tab", { name: "Data" }).click();
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Bar Chart" }).click();
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await expect(dialog.getByLabel("Enable click action")).toBeVisible();
+
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("action rules editor shows trigger column for table chart type", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Clickable Cols ${Date.now()}`,
+ );
+
+ try {
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible({ timeout: 15_000 });
+
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select "Data Table" chart type
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+ // Select connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Write query and run it to populate available fields
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.title AS title, m.released AS released LIMIT 5",
+ );
+ await expect(
+ dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)"),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)").click();
+ // Wait for preview to render
+ await expect(getPreview(dialog)).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Navigate to Advanced tab and enable click action
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await dialog.getByLabel("Enable click action").click();
+
+ // Open the action rules editor
+ await dialog.getByRole("button", { name: "Manage Action Rules" }).click();
+ const rulesDialog = page.getByRole("dialog", { name: "Action Rules" });
+ await expect(
+ rulesDialog.getByRole("heading", { name: "Action Rules" }),
+ ).toBeVisible();
+
+ // Add a rule
+ await rulesDialog.getByRole("button", { name: "Add Rule" }).click();
+ await expect(rulesDialog.getByText("Rule 1")).toBeVisible();
+
+ // Should show "Trigger Column" selector for tables
+ await expect(rulesDialog.getByText("Trigger Column")).toBeVisible({
+ timeout: 5_000,
+ });
+ // Should show "Parameter Name" input
+ await expect(rulesDialog.getByText("Parameter Name")).toBeVisible();
+ // Source Field should NOT appear for table chart types
+ await expect(rulesDialog.getByText("Source Field")).not.toBeVisible();
+
+ // Click Done and cancel
+ await rulesDialog.getByRole("button", { name: "Done" }).click();
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ } finally {
+ await cleanup();
+ }
+ });
+});
+
+test.describe("Parameter interpolation in titles", () => {
+ /**
+ * Helper: create a dashboard with a parameter-select widget and a table whose
+ * title contains $param_year.
+ */
+ async function createInterpolatedTitleDashboard(
+ request: import("@playwright/test").APIRequestContext,
+ ) {
+ const res = await request.post("/api/dashboards", {
+ data: { name: `Interpolation ${Date.now()}` },
+ });
+ if (!res.ok()) throw new Error(`Create dashboard failed: ${res.status()}`);
+ const { id } = (await res.json()).data;
+
+ const layout = {
+ version: 2 as const,
+ pages: [
+ {
+ id: "page-interp",
+ title: "Main",
+ widgets: [
+ {
+ id: "interp-param",
+ chartType: "parameter-select",
+ connectionId: "conn-neo4j-001",
+ query: "",
+ settings: {
+ title: "Year Selector",
+ chartOptions: {
+ parameterType: "select",
+ parameterName: "year",
+ seedQuery:
+ "MATCH (m:Movie) RETURN DISTINCT m.released ORDER BY m.released LIMIT 10",
+ },
+ },
+ },
+ {
+ id: "interp-table",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query:
+ "MATCH (m:Movie) WHERE m.released = $param_year RETURN m.title AS title LIMIT 5",
+ settings: {
+ title: "Movies from $param_year",
+ },
+ },
+ ],
+ gridLayout: [
+ { i: "interp-param", x: 0, y: 0, w: 4, h: 3 },
+ { i: "interp-table", x: 4, y: 0, w: 8, h: 5 },
+ ],
+ },
+ ],
+ };
+
+ const putRes = await request.put(`/api/dashboards/${id}`, {
+ data: { layoutJson: layout },
+ });
+ if (!putRes.ok())
+ throw new Error(`Update dashboard failed: ${putRes.status()}`);
+
+ return {
+ id,
+ cleanup: async () => {
+ await request.delete(`/api/dashboards/${id}`);
+ },
+ };
+ }
+
+ test("widget title interpolates parameter values", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createInterpolatedTitleDashboard(
+ page.request,
+ );
+
+ try {
+ await page.goto(`/${id}`);
+
+ // Initially, the title should show the raw token because param is not set
+ await expect(page.getByText("Movies from $param_year")).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Select a year from the parameter dropdown
+ const paramTrigger = page.getByText("Select a value…");
+ await paramTrigger.click();
+ // Pick the first available year option
+ await expect(async () => {
+ await page.getByRole("option").first().click({ timeout: 2_000 });
+ }).toPass({ timeout: 15_000 });
+
+ // After selecting a value, the raw token should no longer be visible
+ // and the title should contain "Movies from" followed by a year number
+ await expect(page.getByText("Movies from $param_year")).not.toBeVisible({
+ timeout: 5_000,
+ });
+ // The widget card should show the interpolated title
+ await expect(page.getByText(/Movies from \d{4}/)).toBeVisible({
+ timeout: 5_000,
+ });
+ } finally {
+ await cleanup();
+ }
+ });
+});
+
+test.describe("Clickable columns restriction", () => {
+ /**
+ * Helper: create a dashboard with a table that has clickableColumns restricted
+ * to only "title".
+ */
+ async function createRestrictedColumnsDashboard(
+ request: import("@playwright/test").APIRequestContext,
+ ) {
+ const res = await request.post("/api/dashboards", {
+ data: { name: `Restricted Cols ${Date.now()}` },
+ });
+ if (!res.ok()) throw new Error(`Create dashboard failed: ${res.status()}`);
+ const { id } = (await res.json()).data;
+
+ const layout = {
+ version: 2 as const,
+ pages: [
+ {
+ id: "page-restrict",
+ title: "Main",
+ widgets: [
+ {
+ id: "rc-w1",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query:
+ "MATCH (m:Movie) RETURN m.title AS title, m.released AS released ORDER BY m.title LIMIT 20",
+ settings: {
+ title: "Movies (click title only)",
+ clickAction: {
+ type: "set-parameter",
+ parameterMapping: {
+ parameterName: "param_movie",
+ sourceField: "",
+ },
+ clickableColumns: ["title"],
+ },
+ },
+ },
+ ],
+ gridLayout: [{ i: "rc-w1", x: 0, y: 0, w: 12, h: 6 }],
+ },
+ ],
+ };
+
+ const putRes = await request.put(`/api/dashboards/${id}`, {
+ data: { layoutJson: layout },
+ });
+ if (!putRes.ok())
+ throw new Error(`Update dashboard failed: ${putRes.status()}`);
+
+ return {
+ id,
+ cleanup: async () => {
+ await request.delete(`/api/dashboards/${id}`);
+ },
+ };
+ }
+
+ test("only restricted columns show link styling and respond to clicks", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createRestrictedColumnsDashboard(
+ page.request,
+ );
+
+ try {
+ await page.goto(`/${id}`);
+
+ // Wait for the table to render (query + rendering can be slow under load)
+ const titleCell = page.locator("td").filter({ hasText: "Apollo 13" });
+ await expect(titleCell.first()).toBeVisible({ timeout: 30_000 });
+
+ // Title cells should have clickable styling (cursor-pointer on td, badge span inside)
+ await expect(titleCell.first()).toHaveClass(/cursor-pointer/);
+ // The badge span inside the title cell should have text-primary
+ const badge = titleCell.first().locator("span.rounded-md");
+ await expect(badge).toBeVisible();
+
+ // Released cells (year numbers) should NOT have clickable styling.
+ // Find a released cell in the same row as "Apollo 13" — the year is 1995.
+ const releasedCell = page.locator("td").filter({ hasText: "1995" });
+ await expect(releasedCell.first()).not.toHaveClass(/cursor-pointer/);
+
+ // Click the released cell — should NOT set a parameter
+ await releasedCell.first().click();
+ await expect(page.getByText("Reset")).not.toBeVisible({ timeout: 2_000 });
+
+ // Click the title cell — SHOULD set a parameter
+ await titleCell.first().click();
+ await expect(page.getByText("Reset")).toBeVisible({ timeout: 5_000 });
+ } finally {
+ await cleanup();
+ }
+ });
+});
+
+test.describe("Multi-rule click actions", () => {
+ /**
+ * Helper: create a dashboard with a table that has two action rules:
+ * - Click "title" column → set param_movie
+ * - Click "released" column → set param_year
+ */
+ async function createMultiRuleDashboard(
+ request: import("@playwright/test").APIRequestContext,
+ ) {
+ const res = await request.post("/api/dashboards", {
+ data: { name: `Multi Rule ${Date.now()}` },
+ });
+ if (!res.ok()) throw new Error(`Create dashboard failed: ${res.status()}`);
+ const { id } = (await res.json()).data;
+
+ const layout = {
+ version: 2 as const,
+ pages: [
+ {
+ id: "page-multi",
+ title: "Main",
+ widgets: [
+ {
+ id: "mr-w1",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query:
+ "MATCH (m:Movie) RETURN m.title AS title, m.released AS released ORDER BY m.title LIMIT 20",
+ settings: {
+ title: "Movies (multi-rule)",
+ clickAction: {
+ type: "set-parameter",
+ rules: [
+ {
+ id: "rule-title",
+ triggerColumn: "title",
+ type: "set-parameter",
+ parameterMapping: {
+ parameterName: "param_movie",
+ sourceField: "title",
+ },
+ },
+ {
+ id: "rule-year",
+ triggerColumn: "released",
+ type: "set-parameter",
+ parameterMapping: {
+ parameterName: "param_year",
+ sourceField: "released",
+ },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ gridLayout: [{ i: "mr-w1", x: 0, y: 0, w: 12, h: 6 }],
+ },
+ ],
+ };
+
+ const putRes = await request.put(`/api/dashboards/${id}`, {
+ data: { layoutJson: layout },
+ });
+ if (!putRes.ok())
+ throw new Error(`Update dashboard failed: ${putRes.status()}`);
+
+ return {
+ id,
+ cleanup: async () => {
+ await request.delete(`/api/dashboards/${id}`);
+ },
+ };
+ }
+
+ test("multi-rule table: clicking different columns sets different parameters", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createMultiRuleDashboard(page.request);
+
+ try {
+ await page.goto(`/${id}`);
+
+ // Wait for table to load
+ const titleCell = page.locator("td").filter({ hasText: "Apollo 13" });
+ await expect(titleCell.first()).toBeVisible({ timeout: 15_000 });
+
+ // Both title and released columns should have badge styling
+ await expect(titleCell.first()).toHaveClass(/cursor-pointer/);
+ const titleBadge = titleCell.first().locator("span.rounded-md");
+ await expect(titleBadge).toBeVisible();
+
+ const yearCell = page.locator("td").filter({ hasText: "1995" });
+ await expect(yearCell.first()).toHaveClass(/cursor-pointer/);
+
+ // Click title column — should set param_movie
+ await titleCell.first().click();
+ await expect(page.getByText("Reset")).toBeVisible({ timeout: 5_000 });
+
+ // Reset
+ await page.getByText("Reset").first().click();
+ await expect(page.getByText("Reset")).not.toBeVisible({ timeout: 5_000 });
+
+ // Click year column — should set param_year
+ await yearCell.first().click();
+ await expect(page.getByText("Reset")).toBeVisible({ timeout: 5_000 });
+ } finally {
+ await cleanup();
+ }
+ });
+});
+
+// ── Parameter type injection tests ─────────────────────────────────────────
+// Cover parameter types that have Vitest coverage but no E2E testing:
+// date, date-range, date-relative, number-range, multi-select, cascading-select
+
+test.describe("Date parameter widget", () => {
+ async function createDateParamDashboard(
+ request: import("@playwright/test").APIRequestContext,
+ ) {
+ const res = await request.post("/api/dashboards", {
+ data: { name: `Date Param ${Date.now()}` },
+ });
+ if (!res.ok()) throw new Error(`Create dashboard failed: ${res.status()}`);
+ const { id } = (await res.json()).data;
+
+ const layout = {
+ version: 2 as const,
+ pages: [
+ {
+ id: "page-date",
+ title: "Main",
+ widgets: [
+ {
+ id: "date-param",
+ chartType: "parameter-select",
+ connectionId: "",
+ query: "",
+ settings: {
+ title: "Date Picker",
+ chartOptions: {
+ parameterType: "date",
+ parameterName: "test_date",
+ },
+ },
+ },
+ {
+ id: "date-table",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query:
+ "MATCH (m:Movie) RETURN m.title AS title, m.released AS year ORDER BY m.released LIMIT 5",
+ settings: {
+ title: "Movies (date: $param_test_date)",
+ chartOptions: {},
+ },
+ },
+ ],
+ gridLayout: [
+ { i: "date-param", x: 0, y: 0, w: 4, h: 3 },
+ { i: "date-table", x: 4, y: 0, w: 8, h: 5 },
+ ],
+ },
+ ],
+ };
+
+ const putRes = await request.put(`/api/dashboards/${id}`, {
+ data: { layoutJson: layout },
+ });
+ if (!putRes.ok())
+ throw new Error(`Update dashboard failed: ${putRes.status()}`);
+
+ return {
+ id,
+ cleanup: async () => {
+ await request.delete(`/api/dashboards/${id}`);
+ },
+ };
+ }
+
+ test("date picker widget renders and allows date selection", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createDateParamDashboard(page.request);
+
+ try {
+ await page.goto(`/${id}`);
+
+ // Wait for the date picker widget to render
+ await expect(page.getByText("Pick a date…")).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Open the calendar popover
+ await page.getByText("Pick a date…").click();
+
+ // The calendar popover should render day cells
+ await expect(page.locator("[role='gridcell']").first()).toBeVisible({
+ timeout: 5_000,
+ });
+
+ // Click a day in the calendar
+ await page
+ .locator("[role='gridcell']")
+ .filter({ hasNotText: "" })
+ .nth(10)
+ .click();
+
+ // After selecting, the "Pick a date…" placeholder should be replaced with a formatted date
+ await expect(page.getByText("Pick a date…")).not.toBeVisible({
+ timeout: 5_000,
+ });
+ // Verify the selected date displays in "MMM d, yyyy" format
+ await expect(page.getByText(/\w{3} \d{1,2}, \d{4}/)).toBeVisible();
+
+ // The title should interpolate the parameter value
+ await expect(
+ page.getByText("Movies (date: $param_test_date)"),
+ ).not.toBeVisible({
+ timeout: 5_000,
+ });
+ } finally {
+ await cleanup();
+ }
+ });
+});
+
+test.describe("Date-range parameter widget", () => {
+ async function createDateRangeParamDashboard(
+ request: import("@playwright/test").APIRequestContext,
+ ) {
+ const res = await request.post("/api/dashboards", {
+ data: { name: `DateRange Param ${Date.now()}` },
+ });
+ if (!res.ok()) throw new Error(`Create dashboard failed: ${res.status()}`);
+ const { id } = (await res.json()).data;
+
+ const layout = {
+ version: 2 as const,
+ pages: [
+ {
+ id: "page-drange",
+ title: "Main",
+ widgets: [
+ {
+ id: "drange-param",
+ chartType: "parameter-select",
+ connectionId: "",
+ query: "",
+ settings: {
+ title: "Date Range",
+ chartOptions: {
+ parameterType: "date-range",
+ parameterName: "test_daterange",
+ },
+ },
+ },
+ ],
+ gridLayout: [{ i: "drange-param", x: 0, y: 0, w: 6, h: 3 }],
+ },
+ ],
+ };
+
+ const putRes = await request.put(`/api/dashboards/${id}`, {
+ data: { layoutJson: layout },
+ });
+ if (!putRes.ok())
+ throw new Error(`Update dashboard failed: ${putRes.status()}`);
+
+ return {
+ id,
+ cleanup: async () => {
+ await request.delete(`/api/dashboards/${id}`);
+ },
+ };
+ }
+
+ test("date-range picker renders and allows preset selection", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createDateRangeParamDashboard(page.request);
+
+ try {
+ await page.goto(`/${id}`);
+
+ // Wait for the date-range picker widget to render
+ await expect(page.getByText("Pick a date range…")).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Open the calendar popover
+ await page.getByText("Pick a date range…").click();
+
+ // The preset sidebar should show "Last 7 days" button
+ const preset = page.getByRole("button", { name: "Last 7 days" });
+ await expect(preset).toBeVisible({ timeout: 5_000 });
+
+ // Click the "Last 7 days" preset
+ await preset.click();
+
+ // After selection, the placeholder should be replaced with a date range
+ // Format: "MMM d, yyyy – MMM d, yyyy"
+ await expect(page.getByText("Pick a date range…")).not.toBeVisible({
+ timeout: 5_000,
+ });
+ await expect(
+ page.getByText(/\w{3} \d{1,2}, \d{4}\s*–\s*\w{3} \d{1,2}, \d{4}/),
+ ).toBeVisible();
+
+ // Clear button should appear
+ const clearBtn = page.getByRole("button", {
+ name: "Clear test_daterange",
+ });
+ await expect(clearBtn).toBeVisible();
+
+ // Click clear to reset
+ await clearBtn.click();
+ await expect(page.getByText("Pick a date range…")).toBeVisible({
+ timeout: 5_000,
+ });
+ } finally {
+ await cleanup();
+ }
+ });
+});
+
+test.describe("Date-relative parameter widget", () => {
+ async function createDateRelativeParamDashboard(
+ request: import("@playwright/test").APIRequestContext,
+ ) {
+ const res = await request.post("/api/dashboards", {
+ data: { name: `DateRelative Param ${Date.now()}` },
+ });
+ if (!res.ok()) throw new Error(`Create dashboard failed: ${res.status()}`);
+ const { id } = (await res.json()).data;
+
+ const layout = {
+ version: 2 as const,
+ pages: [
+ {
+ id: "page-drel",
+ title: "Main",
+ widgets: [
+ {
+ id: "drel-param",
+ chartType: "parameter-select",
+ connectionId: "",
+ query: "",
+ settings: {
+ title: "Relative Date",
+ chartOptions: {
+ parameterType: "date-relative",
+ parameterName: "test_reldate",
+ },
+ },
+ },
+ ],
+ gridLayout: [{ i: "drel-param", x: 0, y: 0, w: 12, h: 3 }],
+ },
+ ],
+ };
+
+ const putRes = await request.put(`/api/dashboards/${id}`, {
+ data: { layoutJson: layout },
+ });
+ if (!putRes.ok())
+ throw new Error(`Update dashboard failed: ${putRes.status()}`);
+
+ return {
+ id,
+ cleanup: async () => {
+ await request.delete(`/api/dashboards/${id}`);
+ },
+ };
+ }
+
+ test("date-relative picker renders preset buttons and supports toggle", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createDateRelativeParamDashboard(
+ page.request,
+ );
+
+ try {
+ await page.goto(`/${id}`);
+
+ // Scope to the widget card to avoid collision with parameter bar tags
+ const card = page.getByTestId("widget-card");
+
+ // Wait for the relative date preset buttons to render
+ const todayBtn = card.getByRole("button", { name: "Today" });
+ await expect(todayBtn).toBeVisible({ timeout: 15_000 });
+
+ // All presets should be visible
+ await expect(
+ card.getByRole("button", { name: "Yesterday" }),
+ ).toBeVisible();
+ await expect(
+ card.getByRole("button", { name: "Last 7 days" }),
+ ).toBeVisible();
+ await expect(
+ card.getByRole("button", { name: "Last 30 days" }),
+ ).toBeVisible();
+ await expect(
+ card.getByRole("button", { name: "This month" }),
+ ).toBeVisible();
+ await expect(
+ card.getByRole("button", { name: "This year" }),
+ ).toBeVisible();
+
+ // Initially none should be active
+ await expect(todayBtn).toHaveAttribute("aria-pressed", "false");
+
+ // Click "Today" — should become active
+ await todayBtn.click();
+ await expect(todayBtn).toHaveAttribute("aria-pressed", "true");
+
+ // Click "Today" again — should toggle off
+ await todayBtn.click();
+ await expect(todayBtn).toHaveAttribute("aria-pressed", "false");
+
+ // Click "Last 7 days" — should become active
+ const last7 = card.getByRole("button", { name: "Last 7 days" });
+ await last7.click();
+ await expect(last7).toHaveAttribute("aria-pressed", "true");
+ // "Today" should still be inactive
+ await expect(todayBtn).toHaveAttribute("aria-pressed", "false");
+ } finally {
+ await cleanup();
+ }
+ });
+});
+
+test.describe("Number-range parameter widget", () => {
+ async function createNumberRangeParamDashboard(
+ request: import("@playwright/test").APIRequestContext,
+ ) {
+ const res = await request.post("/api/dashboards", {
+ data: { name: `NumRange Param ${Date.now()}` },
+ });
+ if (!res.ok()) throw new Error(`Create dashboard failed: ${res.status()}`);
+ const { id } = (await res.json()).data;
+
+ const layout = {
+ version: 2 as const,
+ pages: [
+ {
+ id: "page-numrange",
+ title: "Main",
+ widgets: [
+ {
+ id: "numrange-param",
+ chartType: "parameter-select",
+ connectionId: "",
+ query: "",
+ settings: {
+ title: "Year Range",
+ chartOptions: {
+ parameterType: "number-range",
+ parameterName: "test_numrange",
+ rangeMin: 1900,
+ rangeMax: 2020,
+ rangeStep: 1,
+ },
+ },
+ },
+ ],
+ gridLayout: [{ i: "numrange-param", x: 0, y: 0, w: 6, h: 3 }],
+ },
+ ],
+ };
+
+ const putRes = await request.put(`/api/dashboards/${id}`, {
+ data: { layoutJson: layout },
+ });
+ if (!putRes.ok())
+ throw new Error(`Update dashboard failed: ${putRes.status()}`);
+
+ return {
+ id,
+ cleanup: async () => {
+ await request.delete(`/api/dashboards/${id}`);
+ },
+ };
+ }
+
+ test("number-range slider renders inputs and supports interaction", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createNumberRangeParamDashboard(page.request);
+
+ try {
+ await page.goto(`/${id}`);
+
+ // Wait for the number-range widget to render — min/max inputs
+ const minInput = page.getByLabel("test_numrange minimum");
+ const maxInput = page.getByLabel("test_numrange maximum");
+ await expect(minInput).toBeVisible({ timeout: 15_000 });
+ await expect(maxInput).toBeVisible();
+
+ // Default values should match rangeMin/rangeMax
+ await expect(minInput).toHaveValue("1900");
+ await expect(maxInput).toHaveValue("2020");
+
+ // Change the min input — should trigger parameter set and show Reset button.
+ // Two "Reset" buttons may appear (slider + parameter bar), so use .first().
+ await minInput.fill("1950");
+ await expect(
+ page.getByRole("button", { name: "Reset" }).first(),
+ ).toBeVisible({ timeout: 5_000 });
+
+ // Change the max input
+ await maxInput.fill("2000");
+ await expect(maxInput).toHaveValue("2000");
+
+ // Click Reset — should clear the range (both slider and parameter bar Reset disappear)
+ await page.getByRole("button", { name: "Reset" }).first().click();
+ await expect(
+ page.getByRole("button", { name: "Reset" }).first(),
+ ).not.toBeVisible({ timeout: 5_000 });
+ } finally {
+ await cleanup();
+ }
+ });
+});
+
+test.describe("Multi-select parameter widget", () => {
+ async function createMultiSelectParamDashboard(
+ request: import("@playwright/test").APIRequestContext,
+ ) {
+ const res = await request.post("/api/dashboards", {
+ data: { name: `MultiSelect Param ${Date.now()}` },
+ });
+ if (!res.ok()) throw new Error(`Create dashboard failed: ${res.status()}`);
+ const { id } = (await res.json()).data;
+
+ const layout = {
+ version: 2 as const,
+ pages: [
+ {
+ id: "page-multiselect",
+ title: "Main",
+ widgets: [
+ {
+ id: "multiselect-param",
+ chartType: "parameter-select",
+ connectionId: "conn-neo4j-001",
+ query: "",
+ settings: {
+ title: "Person Selector",
+ chartOptions: {
+ parameterType: "multi-select",
+ parameterName: "test_people",
+ seedQuery:
+ "MATCH (p:Person) RETURN p.name AS value, p.name AS label ORDER BY p.name LIMIT 10",
+ },
+ },
+ },
+ {
+ id: "multiselect-table",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query:
+ "MATCH (p:Person)-[r:ACTED_IN]->(m:Movie) WHERE p.name IN $param_test_people RETURN p.name AS person, m.title AS movie ORDER BY p.name LIMIT 20",
+ settings: {
+ title: "Filmography",
+ chartOptions: {},
+ },
+ },
+ ],
+ gridLayout: [
+ { i: "multiselect-param", x: 0, y: 0, w: 4, h: 3 },
+ { i: "multiselect-table", x: 4, y: 0, w: 8, h: 5 },
+ ],
+ },
+ ],
+ };
+
+ const putRes = await request.put(`/api/dashboards/${id}`, {
+ data: { layoutJson: layout },
+ });
+ if (!putRes.ok())
+ throw new Error(`Update dashboard failed: ${putRes.status()}`);
+
+ return {
+ id,
+ cleanup: async () => {
+ await request.delete(`/api/dashboards/${id}`);
+ },
+ };
+ }
+
+ test("multi-select widget renders and allows selecting multiple values", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createMultiSelectParamDashboard(page.request);
+
+ try {
+ await page.goto(`/${id}`);
+
+ // Wait for the multi-select widget to load its options
+ await expect(page.getByText("Select values…")).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Open the multi-select dropdown
+ await page.getByText("Select values…").click();
+
+ // Options should load from the seed query
+ await expect(page.getByRole("option").first()).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Select the first option
+ await page.getByRole("option").first().click();
+
+ // Select the second option (dropdown stays open for multi-select)
+ await page.getByRole("option").nth(1).click();
+
+ // Close the dropdown by pressing Escape
+ await page.keyboard.press("Escape");
+
+ // "Select values…" placeholder should be gone — values are now selected
+ await expect(page.getByText("Select values…")).not.toBeVisible({
+ timeout: 5_000,
+ });
+
+ // "Clear" button should appear (confirms values are selected)
+ await expect(page.getByText("Clear")).toBeVisible();
+
+ // The dependent table should re-execute without errors
+ await expect(page.locator("text=Query Failed")).not.toBeVisible({
+ timeout: 10_000,
+ });
+ } finally {
+ await cleanup();
+ }
+ });
+});
+
+test.describe("Cascading-select parameter widget", () => {
+ async function createCascadingSelectDashboard(
+ request: import("@playwright/test").APIRequestContext,
+ ) {
+ const res = await request.post("/api/dashboards", {
+ data: { name: `Cascading Param ${Date.now()}` },
+ });
+ if (!res.ok()) throw new Error(`Create dashboard failed: ${res.status()}`);
+ const { id } = (await res.json()).data;
+
+ const layout = {
+ version: 2 as const,
+ pages: [
+ {
+ id: "page-cascade",
+ title: "Main",
+ widgets: [
+ {
+ id: "cascade-parent",
+ chartType: "parameter-select",
+ connectionId: "conn-neo4j-001",
+ query: "",
+ settings: {
+ title: "Director",
+ chartOptions: {
+ parameterType: "select",
+ parameterName: "test_director",
+ seedQuery:
+ "MATCH (p:Person)-[:DIRECTED]->(m:Movie) RETURN DISTINCT p.name AS value, p.name AS label ORDER BY p.name",
+ },
+ },
+ },
+ {
+ id: "cascade-child",
+ chartType: "parameter-select",
+ connectionId: "conn-neo4j-001",
+ query: "",
+ settings: {
+ title: "Movie by Director",
+ chartOptions: {
+ parameterType: "cascading-select",
+ parameterName: "test_dir_movie",
+ parentParameterName: "test_director",
+ seedQuery:
+ "MATCH (p:Person)-[:DIRECTED]->(m:Movie) WHERE p.name = $param_test_director RETURN m.title AS value, m.title AS label ORDER BY m.title",
+ },
+ },
+ },
+ {
+ id: "cascade-table",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query:
+ "MATCH (p:Person)-[r:ACTED_IN]->(m:Movie) WHERE m.title = $param_test_dir_movie RETURN p.name AS actor, r.roles AS roles",
+ settings: {
+ title: "Cast",
+ chartOptions: {},
+ },
+ },
+ ],
+ gridLayout: [
+ { i: "cascade-parent", x: 0, y: 0, w: 4, h: 2 },
+ { i: "cascade-child", x: 4, y: 0, w: 4, h: 2 },
+ { i: "cascade-table", x: 0, y: 2, w: 12, h: 4 },
+ ],
+ },
+ ],
+ };
+
+ const putRes = await request.put(`/api/dashboards/${id}`, {
+ data: { layoutJson: layout },
+ });
+ if (!putRes.ok())
+ throw new Error(`Update dashboard failed: ${putRes.status()}`);
+
+ return {
+ id,
+ cleanup: async () => {
+ await request.delete(`/api/dashboards/${id}`);
+ },
+ };
+ }
+
+ test("cascading-select depends on parent and re-fetches options", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createCascadingSelectDashboard(page.request);
+
+ try {
+ await page.goto(`/${id}`);
+
+ // Wait for the parent select widget to load
+ await expect(page.getByText("Select a value…").first()).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // The cascading child should show "Select test_director first…"
+ await expect(page.getByText("Select test_director first…")).toBeVisible();
+
+ // The "depends on" label should be visible
+ await expect(page.getByText("depends on test_director")).toBeVisible();
+
+ // Select a director from the parent dropdown
+ await page.getByText("Select a value…").first().click();
+ await expect(async () => {
+ await page.getByRole("option").first().click({ timeout: 2_000 });
+ }).toPass({ timeout: 15_000 });
+
+ // After selecting the parent, the child should no longer show the
+ // "Select test_director first…" placeholder — it should either show
+ // "Select a value…" (options loaded) or be loading
+ await expect(
+ page.getByText("Select test_director first…"),
+ ).not.toBeVisible({
+ timeout: 10_000,
+ });
+
+ // The cascading child should now show "Select a value…"
+ await expect(page.getByText("Select a value…")).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Select a movie from the cascading child
+ await page.getByText("Select a value…").click();
+ await expect(async () => {
+ await page.getByRole("option").first().click({ timeout: 2_000 });
+ }).toPass({ timeout: 15_000 });
+
+ // The dependent table should execute without errors
+ await expect(page.locator("text=Query Failed")).not.toBeVisible({
+ timeout: 10_000,
+ });
+ } finally {
+ await cleanup();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Action rules — multi-rule editor (extended coverage)
+// ---------------------------------------------------------------------------
+
+test.describe("Action rules — multi-rule editor", () => {
+ test("should add multiple action rules and configure navigate-to-page", async ({
+ authPage,
+ page,
+ }) => {
+ test.setTimeout(60_000);
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Multi Action ${Date.now()}`,
+ );
+
+ try {
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible({ timeout: 15_000 });
+
+ // Add a second page for navigate-to-page
+ await page.getByRole("button", { name: "Add page" }).click();
+ await expect(page.getByText("Page 2")).toBeVisible({ timeout: 5_000 });
+
+ // Add a table widget
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Wait for editor to be ready after connection selection
+ await expect(
+ dialog.locator("[data-testid='codemirror-container']"),
+ ).toBeVisible({
+ timeout: 5_000,
+ });
+
+ // Write query and run
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.title AS title, m.released AS released LIMIT 10",
+ );
+ await expect(
+ dialog.getByTitle("Run query (Ctrl+Enter / \u2318+Enter)"),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByTitle("Run query (Ctrl+Enter / \u2318+Enter)").click();
+ await expect(getPreview(dialog)).toBeVisible({ timeout: 15_000 });
+
+ // Navigate to Advanced tab and enable click action
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await dialog.getByLabel("Enable click action").click();
+
+ // Open action rules editor
+ await dialog.getByRole("button", { name: "Manage Action Rules" }).click();
+ const rulesDialog = page.getByRole("dialog", { name: "Action Rules" });
+
+ // Add first rule — change to "Navigate to Page"
+ await rulesDialog.getByRole("button", { name: "Add Rule" }).click();
+ await expect(rulesDialog.getByText("Rule 1")).toBeVisible();
+
+ // Change action type to Navigate to Page
+ await rulesDialog.getByLabel("Action Type").click();
+ await page.getByRole("option", { name: "Navigate to Page" }).click();
+
+ // Should show Target Page selector with Page 2
+ await expect(rulesDialog.getByText("Target Page")).toBeVisible({
+ timeout: 5_000,
+ });
+
+ // Add second rule — Set Parameter & Navigate
+ await rulesDialog.getByRole("button", { name: "Add Rule" }).click();
+ await expect(rulesDialog.getByText("Rule 2")).toBeVisible();
+
+ // Done
+ await rulesDialog.getByRole("button", { name: "Done" }).click();
+
+ // Verify rule count
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await expect(
+ dialog.getByText("2 action rule(s) configured."),
+ ).toBeVisible();
+
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("should delete an action rule", async ({ authPage, page }) => {
+ test.setTimeout(60_000);
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Delete Action ${Date.now()}`,
+ );
+
+ try {
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible({ timeout: 15_000 });
+
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Wait for editor to be ready after connection selection
+ await expect(
+ dialog.locator("[data-testid='codemirror-container']"),
+ ).toBeVisible({
+ timeout: 5_000,
+ });
+
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.title AS title, m.released AS released LIMIT 5",
+ );
+ await expect(
+ dialog.getByTitle("Run query (Ctrl+Enter / \u2318+Enter)"),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByTitle("Run query (Ctrl+Enter / \u2318+Enter)").click();
+ await expect(getPreview(dialog)).toBeVisible({ timeout: 15_000 });
+
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await dialog.getByLabel("Enable click action").click();
+ await dialog.getByRole("button", { name: "Manage Action Rules" }).click();
+
+ const rulesDialog = page.getByRole("dialog", { name: "Action Rules" });
+
+ // Add 2 rules
+ await rulesDialog.getByRole("button", { name: "Add Rule" }).click();
+ await expect(rulesDialog.getByText("Rule 1")).toBeVisible();
+ await rulesDialog.getByRole("button", { name: "Add Rule" }).click();
+ await expect(rulesDialog.getByText("Rule 2")).toBeVisible();
+
+ // Delete Rule 1
+ await rulesDialog.getByRole("button", { name: "Delete rule 1" }).click();
+
+ // Should now show only 1 rule
+ await expect(rulesDialog.getByText("Rule 2")).not.toBeVisible();
+ await expect(rulesDialog.getByText("Rule 1")).toBeVisible();
+
+ // Done
+ await rulesDialog.getByRole("button", { name: "Done" }).click();
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await expect(
+ dialog.getByText("1 action rule(s) configured."),
+ ).toBeVisible();
+
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("should show trigger column selector for table type", async ({
+ authPage,
+ page,
+ }) => {
+ test.setTimeout(60_000);
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Trigger Col ${Date.now()}`,
+ );
+
+ try {
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible({ timeout: 15_000 });
+
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select Data Table
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Wait for editor to be ready after connection selection
+ await expect(
+ dialog.locator("[data-testid='codemirror-container']"),
+ ).toBeVisible({
+ timeout: 5_000,
+ });
+
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.title AS title, m.released AS released LIMIT 5",
+ );
+ await expect(
+ dialog.getByTitle("Run query (Ctrl+Enter / \u2318+Enter)"),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByTitle("Run query (Ctrl+Enter / \u2318+Enter)").click();
+ await expect(getPreview(dialog)).toBeVisible({ timeout: 15_000 });
+
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await dialog.getByLabel("Enable click action").click();
+ await dialog.getByRole("button", { name: "Manage Action Rules" }).click();
+
+ const rulesDialog = page.getByRole("dialog", { name: "Action Rules" });
+
+ // Add a rule — table type should show "Trigger Column"
+ await rulesDialog.getByRole("button", { name: "Add Rule" }).click();
+ await expect(rulesDialog.getByText("Trigger Column")).toBeVisible({
+ timeout: 5_000,
+ });
+ // Source Field should NOT appear for table type
+ await expect(rulesDialog.getByText("Source Field")).not.toBeVisible();
+
+ await rulesDialog.getByRole("button", { name: "Done" }).click();
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ } finally {
+ await cleanup();
+ }
+ });
+});
+
+test.describe("Preview Run button", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("Run button is visible in preview column from all tabs", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Preview Run ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Run button should be visible on Data tab
+ const runButton = dialog.getByRole("button", { name: "Run" });
+ // There may be two Run buttons (one in query editor, one in preview column)
+ // The preview column one should always be visible
+ await expect(runButton.first()).toBeVisible();
+
+ // Switch to Style tab — Run button in preview column should still be visible
+ await dialog.getByRole("tab", { name: "Style" }).click();
+ await expect(runButton.first()).toBeVisible();
+
+ // Switch to Advanced tab — Run button in preview column should still be visible
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await expect(runButton.first()).toBeVisible();
+
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ });
+});
+
+test.describe("Parameter bar filter toggle", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("filter button in toolbar toggles parameter bar visibility", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+
+ // Create a dashboard via API with a click-action table widget
+ const res = await page.request.post("/api/dashboards", {
+ data: { name: `FilterToggle ${Date.now()}` },
+ });
+ const { id } = (await res.json()).data;
+ dashboardCleanup = async () => {
+ await page.request.delete(`/api/dashboards/${id}`);
+ };
+
+ const layout = {
+ version: 2 as const,
+ pages: [
+ {
+ id: "p1",
+ title: "Main",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query:
+ "MATCH (m:Movie) RETURN m.title AS title, m.released AS released ORDER BY m.title LIMIT 10",
+ settings: {
+ title: "Movies",
+ clickAction: {
+ type: "set-parameter" as const,
+ parameterMapping: {
+ parameterName: "selected_movie",
+ sourceField: "",
+ },
+ },
+ },
+ },
+ ],
+ gridLayout: [{ i: "w1", x: 0, y: 0, w: 12, h: 6 }],
+ },
+ ],
+ };
+ await page.request.put(`/api/dashboards/${id}`, {
+ data: { layoutJson: layout },
+ });
+
+ // Navigate to view mode
+ await page.goto(`/${id}`);
+ await expect(page.getByText("Movies")).toBeVisible({ timeout: 15_000 });
+
+ // Wait for table data to load and click a cell to set a parameter
+ const firstCell = page.locator("td").first();
+ await expect(firstCell).toBeVisible({ timeout: 15_000 });
+ await firstCell.click();
+
+ // Parameter bar auto-shows when first parameter is set
+ await expect(page.getByRole("button", { name: "Reset" })).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Filter button should say "Hide parameters" (bar is visible)
+ const hideBtn = page.getByRole("button", { name: "Hide parameters" });
+ await expect(hideBtn).toBeVisible();
+
+ // Click to hide the parameter bar
+ await hideBtn.click();
+
+ // Parameter bar "Reset" button should be hidden
+ await expect(page.getByRole("button", { name: "Reset" })).not.toBeVisible();
+
+ // Filter button should now say "Show parameters"
+ const showBtn = page.getByRole("button", { name: "Show parameters" });
+ await expect(showBtn).toBeVisible();
+
+ // Click to show again
+ await showBtn.click();
+ await expect(page.getByRole("button", { name: "Reset" })).toBeVisible();
+ });
+});
+
+test.describe("Param-select searchable default", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("param-select defaults to searchable (Command popover with search input)", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Searchable Default ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select "Parameter Selector" chart type
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Parameter Selector" }).click();
+
+ // Select Neo4j connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Fill seed query
+ await dialog
+ .locator("#seed-query")
+ .fill(
+ "MATCH (m:Movie) RETURN DISTINCT m.released ORDER BY m.released LIMIT 10",
+ );
+
+ // Set parameter name
+ const paramInput = dialog.getByLabel("Parameter Name");
+ await expect(paramInput).toBeVisible({ timeout: 5_000 });
+ await paramInput.fill("year_searchable_default");
+
+ // Add widget WITHOUT toggling the searchable option — it should be on by default
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog).not.toBeVisible({ timeout: 10_000 });
+
+ // The widget should render a combobox button (searchable ParamSelector),
+ // not a basic Radix Select trigger.
+ // The combobox button is rendered by ParamSelector when searchable=true.
+ const combobox = page.getByRole("combobox").last();
+ await expect(combobox).toBeVisible({ timeout: 10_000 });
+ await combobox.click();
+
+ // The Command popover should show a search input with placeholder "Search…"
+ await expect(page.getByPlaceholder("Search\u2026")).toBeVisible({
+ timeout: 5_000,
+ });
+ });
+});
+
+test.describe("Parameter collision warning", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.beforeEach(async ({ authPage, page }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Collision ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+ });
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("shows collision banner when two param-select widgets share the same parameter name", async ({
+ page,
+ }) => {
+ // --- Widget 1: param-select with name "season" ---
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog1 = page.getByRole("dialog", { name: "Add Widget" });
+ await dialog1.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Parameter Selector" }).click();
+ await dialog1.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+ await dialog1.locator("#seed-query").fill("RETURN 1 AS x");
+ const paramInput1 = dialog1.getByLabel("Parameter Name");
+ await expect(paramInput1).toBeVisible({ timeout: 5_000 });
+ await paramInput1.fill("season");
+ await dialog1.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog1).not.toBeVisible();
+
+ // --- Widget 2: param-select with the same name "season" ---
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog2 = page.getByRole("dialog", { name: "Add Widget" });
+ await dialog2.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Parameter Selector" }).click();
+ await dialog2.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+ await dialog2.locator("#seed-query").fill("RETURN 1 AS x");
+ const paramInput2 = dialog2.getByLabel("Parameter Name");
+ await expect(paramInput2).toBeVisible({ timeout: 5_000 });
+ await paramInput2.fill("season");
+
+ // The collision banner should appear
+ await expect(dialog2.getByTestId("param-collision-banner")).toBeVisible({
+ timeout: 5_000,
+ });
+ });
+});
diff --git a/app/e2e/performance.spec.ts b/app/e2e/performance.spec.ts
new file mode 100644
index 000000000..9741e5a30
--- /dev/null
+++ b/app/e2e/performance.spec.ts
@@ -0,0 +1,597 @@
+import type { Page, APIRequestContext } from "@playwright/test";
+import {
+ test,
+ expect,
+ ALICE,
+ TEST_NEO4J_BOLT_URL,
+ TEST_PG_PORT,
+} from "./fixtures";
+
+/**
+ * Creates temporary Neo4j and PostgreSQL connections for a performance test,
+ * using the testcontainer ports supplied by global-setup.ts via env vars.
+ * Returns the connection IDs and a cleanup function to delete them.
+ */
+async function createTestConnections(request: APIRequestContext): Promise<{
+ neo4jConnId: string;
+ pgConnId: string;
+ cleanup: () => Promise;
+}> {
+ const [neo4jRes, pgRes] = await Promise.all([
+ request.post("/api/connections", {
+ data: {
+ name: "Perf: Neo4j (auto-cleanup)",
+ type: "neo4j",
+ config: {
+ uri: TEST_NEO4J_BOLT_URL,
+ username: "neo4j",
+ password: "neoboard123",
+ },
+ },
+ }),
+ request.post("/api/connections", {
+ data: {
+ name: "Perf: PostgreSQL (auto-cleanup)",
+ type: "postgresql",
+ config: {
+ uri: `postgresql://localhost:${TEST_PG_PORT}`,
+ username: "neoboard",
+ password: "neoboard",
+ database: "movies",
+ },
+ },
+ }),
+ ]);
+
+ if (!neo4jRes.ok())
+ throw new Error(
+ `Failed to create Neo4j connection: ${await neo4jRes.text()}`,
+ );
+ if (!pgRes.ok())
+ throw new Error(
+ `Failed to create PostgreSQL connection: ${await pgRes.text()}`,
+ );
+
+ const { id: neo4jConnId } = (await neo4jRes.json()).data as { id: string };
+ const { id: pgConnId } = (await pgRes.json()).data as { id: string };
+
+ return {
+ neo4jConnId,
+ pgConnId,
+ cleanup: () =>
+ Promise.all([
+ request.delete(`/api/connections/${neo4jConnId}`),
+ request.delete(`/api/connections/${pgConnId}`),
+ ]).then(() => undefined),
+ };
+}
+
+/**
+ * Wait for the browser to complete two animation frames.
+ * More reliable than an arbitrary sleep for "paint microtask" settling —
+ * works correctly under CI load and doesn't add unnecessary wall-clock time.
+ */
+async function waitForNextPaint(page: Page): Promise {
+ await page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+ }),
+ );
+}
+
+test.describe("Performance — tab switching", () => {
+ test("tab switch — time to visible + data-loaded state", async ({
+ page,
+ authPage,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+
+ // Navigate to a seeded dashboard that has multiple pages ("Movie Analytics")
+ await page.getByText("Movie Analytics", { exact: true }).click();
+ await page.waitForURL(/\/[\w-]+$/, { timeout: 10_000 });
+
+ // Wait for the initial page load to fully settle.
+ // domcontentloaded is preferred over networkidle — networkidle is flaky
+ // on apps with long-polling or periodic fetches.
+ await page.waitForLoadState("domcontentloaded");
+
+ // Ensure all loading indicators from the first page are gone before timing starts
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-loading="true"]').length === 0,
+ { timeout: 15_000 },
+ );
+
+ const tabs = page.locator('[data-testid="page-tab"]');
+ const tabCount = await tabs.count();
+
+ if (tabCount < 2) {
+ test.skip(
+ true,
+ "Dashboard has fewer than 2 pages — skipping tab-switch timing",
+ );
+ return;
+ }
+
+ const timings: number[] = [];
+
+ for (let i = 1; i < tabCount; i++) {
+ const t0 = await page.evaluate(() => performance.now());
+
+ // dispatchEvent bypasses Playwright's pointer-event interception check.
+ // The react-grid-layout content area (position:relative, flex-1) overlaps
+ // the tab bar at certain scroll positions, causing .click() to time out.
+ // For a timing test the React onClick handler is all that matters.
+ await tabs.nth(i).dispatchEvent("click");
+
+ // Wait until no widget loading skeleton is visible in the current page
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-loading="true"]').length === 0,
+ { timeout: 10_000 },
+ );
+
+ const t1 = await page.evaluate(() => performance.now());
+ const ms = t1 - t0;
+ timings.push(ms);
+
+ console.log(`Tab ${i} switch: ${ms.toFixed(1)} ms`);
+
+ // A tab switch that takes more than 3 s indicates a serious performance problem
+ expect(ms, `Tab ${i} switch exceeded 3 000 ms threshold`).toBeLessThan(
+ 3_000,
+ );
+ }
+
+ const avg = timings.reduce((a, b) => a + b, 0) / timings.length;
+ console.log(
+ `Average tab switch: ${avg.toFixed(1)} ms over ${timings.length} tab(s)`,
+ );
+ });
+});
+
+test.describe("Performance — concurrent multi-connector queries", () => {
+ /**
+ * Creates a dashboard with 30 Neo4j + 30 PostgreSQL single-value widgets,
+ * interleaved in the grid layout, then navigates to it and measures:
+ * - Full load time (navigation start → all 60 queries resolved)
+ * - Grid items rendered count
+ * - Loading widget delta (60 → 0)
+ *
+ * This exercises the per-connector concurrency queues simultaneously,
+ * revealing saturation differences between the two connectors.
+ * Cleans up the dashboard regardless of test outcome.
+ */
+ test("60 interleaved widgets (30 Neo4j + 30 PostgreSQL) — concurrent resolution", async ({
+ page,
+ authPage,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+
+ const NEO4J_COUNT = 30;
+ const PG_COUNT = 30;
+ const TOTAL = NEO4J_COUNT + PG_COUNT;
+
+ // ── 1. Build interleaved widget list ────────────────────────────────────
+ // Alternating Neo4j / PostgreSQL so both queues are hit simultaneously
+ const widgets = Array.from({ length: TOTAL }, (_, i) => {
+ const isNeo4j = i % 2 === 0;
+ return {
+ id: `perf-mc-w${i + 1}`,
+ chartType: "single-value",
+ connectionId: isNeo4j ? "conn-neo4j-001" : "conn-pg-001",
+ query: isNeo4j ? "RETURN 1 AS value" : "SELECT 1 AS value",
+ params: {},
+ settings: {
+ title: `${isNeo4j ? "Neo4j" : "PG"} Widget ${Math.floor(i / 2) + 1}`,
+ },
+ };
+ });
+
+ // 6 widgets per row (w=2 in a 12-column grid)
+ const gridLayout = widgets.map((w, i) => ({
+ i: w.id,
+ x: (i % 6) * 2,
+ y: Math.floor(i / 6) * 2,
+ w: 2,
+ h: 2,
+ }));
+
+ // ── 2. Create the test dashboard ─────────────────────────────────────────
+ const createRes = await page.request.post("/api/dashboards", {
+ data: { name: "Perf: Multi-Connector Concurrent (auto-cleanup)" },
+ });
+ expect(createRes.status()).toBe(201);
+ const { id: dashboardId } = (await createRes.json()).data as { id: string };
+
+ const updateRes = await page.request.put(`/api/dashboards/${dashboardId}`, {
+ data: {
+ layoutJson: {
+ version: 2,
+ pages: [{ id: "page-1", title: "Page 1", widgets, gridLayout }],
+ },
+ },
+ });
+ expect(updateRes.status()).toBe(200);
+
+ // ── 3. Navigate and measure ──────────────────────────────────────────────
+ try {
+ const t0 = Date.now();
+ await page.goto(`/${dashboardId}`);
+
+ // Gate: wait for all TOTAL card containers to mount before checking
+ // loading state — without this, 0 === 0 passes immediately
+ await page.waitForFunction(
+ (count) =>
+ document.querySelectorAll('[data-testid="widget-card"]').length >=
+ count,
+ TOTAL,
+ { timeout: 30_000 },
+ );
+
+ // Pre-resolution snapshot: all cards mounted, queries still in flight
+ const preResolution = await page.evaluate(() => ({
+ totalLoading: document.querySelectorAll('[data-loading="true"]').length,
+ gridItemsRendered: document.querySelectorAll(".react-grid-item").length,
+ }));
+
+ // Wait for every widget (both connectors) to resolve
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-loading="true"]').length === 0,
+ { timeout: 60_000 },
+ );
+
+ await waitForNextPaint(page);
+ const t1 = Date.now();
+ const ms = t1 - t0;
+
+ // Post-resolution snapshot
+ const postResolution = await page.evaluate(() => ({
+ totalLoading: document.querySelectorAll('[data-loading="true"]').length,
+ gridItemsRendered: document.querySelectorAll(".react-grid-item").length,
+ }));
+
+ // ── Report ────────────────────────────────────────────────────────
+ console.log(`\n=== Multi-Connector Concurrent (${TOTAL} widgets) ===`);
+ console.log(` Neo4j widgets : ${NEO4J_COUNT}`);
+ console.log(` PostgreSQL widgets : ${PG_COUNT}`);
+ console.log(` Full load time : ${ms.toFixed(1)} ms`);
+ console.log(
+ ` Loading widgets (pre): ${preResolution.totalLoading} → (post) ${postResolution.totalLoading}`,
+ );
+ console.log(
+ ` Grid items rendered : ${postResolution.gridItemsRendered}`,
+ );
+ console.log("");
+
+ // ── Thresholds ────────────────────────────────────────────────────
+ expect(
+ ms,
+ `${TOTAL}-widget mixed-connector dashboard exceeded 30 000 ms`,
+ ).toBeLessThan(30_000);
+
+ expect(
+ postResolution.gridItemsRendered,
+ `Expected ${TOTAL} grid items, got ${postResolution.gridItemsRendered}`,
+ ).toBe(TOTAL);
+
+ expect(
+ postResolution.totalLoading,
+ "Some widgets still in loading state after timeout",
+ ).toBe(0);
+ } finally {
+ // ── 4. Cleanup ───────────────────────────────────────────────────────
+ await page.request.delete(`/api/dashboards/${dashboardId}`);
+ }
+ });
+});
+
+test.describe("Performance — large dashboard", () => {
+ /**
+ * Creates a dashboard with 100 widgets via the REST API, navigates to it,
+ * and measures:
+ * - Full load time (navigation start → all queries resolved)
+ * - Browser rendering metrics (DOM node count, JS heap, long tasks)
+ *
+ * Cleans up the dashboard regardless of test outcome.
+ */
+ test("large dashboard (100 widgets) — full load time + rendering metrics", async ({
+ page,
+ authPage,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+
+ // ── 1. Create the test dashboard ────────────────────────────────────────
+ const createRes = await page.request.post("/api/dashboards", {
+ data: { name: "Perf: 100-Widget Dashboard (auto-cleanup)" },
+ });
+ expect(createRes.status()).toBe(201);
+ const { id: dashboardId } = (await createRes.json()).data as { id: string };
+
+ // ── 2. Build 100 single-value widgets (lightest renderer, real queries) ─
+ const WIDGET_COUNT = 100;
+ const widgets = Array.from({ length: WIDGET_COUNT }, (_, i) => ({
+ id: `perf-w${i + 1}`,
+ chartType: "single-value",
+ connectionId: "conn-neo4j-001",
+ query: "RETURN 1 AS value",
+ params: {},
+ settings: { title: `Widget ${i + 1}` },
+ }));
+
+ // 6 widgets per row (w=2 in a 12-column grid)
+ const gridLayout = widgets.map((w, i) => ({
+ i: w.id,
+ x: (i % 6) * 2,
+ y: Math.floor(i / 6) * 2,
+ w: 2,
+ h: 2,
+ }));
+
+ const updateRes = await page.request.put(`/api/dashboards/${dashboardId}`, {
+ data: {
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "page-1",
+ title: "Page 1",
+ widgets,
+ gridLayout,
+ },
+ ],
+ },
+ },
+ });
+ expect(updateRes.status()).toBe(200);
+
+ // ── 3. Navigate and measure ──────────────────────────────────────────────
+ try {
+ // Register the PerformanceObserver BEFORE navigating so it runs as an
+ // init script on the next page load (page.goto below).
+ await page.addInitScript(() => {
+ (window as Window & { __longTaskCount?: number }).__longTaskCount = 0;
+ try {
+ new PerformanceObserver((list) => {
+ (window as Window & { __longTaskCount?: number }).__longTaskCount =
+ ((window as Window & { __longTaskCount?: number })
+ .__longTaskCount ?? 0) + list.getEntries().length;
+ }).observe({ type: "longtask", buffered: true });
+ } catch {
+ // longtask observer not supported in this browser
+ }
+ });
+
+ // BUG FIX 1: use Date.now() (Node.js process clock) instead of
+ // page.evaluate(performance.now). The browser's performance.now()
+ // timeline resets to ~0 on every page navigation, so t0 captured on the
+ // list page and t1 captured on the new dashboard page would produce a
+ // large negative number (e.g. −12 039 ms).
+ const t0 = Date.now();
+
+ await page.goto(`/${dashboardId}`);
+
+ // Wait for all WIDGET_COUNT card containers to mount.
+ // This is the critical gate that ensures React has rendered every widget
+ // before we check loading state — without it, 0 loading elements would
+ // pass the next waitForFunction immediately (0 === 0).
+ await page.waitForFunction(
+ (count) =>
+ document.querySelectorAll('[data-testid="widget-card"]').length >=
+ count,
+ WIDGET_COUNT,
+ { timeout: 30_000 },
+ );
+
+ // ── Pre-resolution snapshot: all cards mounted, queries still in flight
+ const preResolutionMetrics = await page.evaluate(() => {
+ const mem = (
+ performance as Performance & {
+ memory?: { usedJSHeapSize: number; totalJSHeapSize: number };
+ }
+ ).memory;
+ return {
+ domNodeCount: document.querySelectorAll("*").length,
+ // loadingEls and gridItemsRendered are the metrics that meaningfully
+ // change for single-value widgets (DOM node count stays constant
+ // because a skeleton and a rendered value have the same complexity).
+ loadingEls: document.querySelectorAll('[data-loading="true"]').length,
+ gridItemsRendered:
+ document.querySelectorAll(".react-grid-item").length,
+ jsHeapUsedMb: mem
+ ? +(mem.usedJSHeapSize / 1_048_576).toFixed(1)
+ : null,
+ };
+ });
+
+ // Wait until every widget loading skeleton has resolved (success or error).
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-loading="true"]').length === 0,
+ { timeout: 60_000 },
+ );
+
+ // Wait for remaining paint microtasks (e.g. ECharts canvas) to settle.
+ await waitForNextPaint(page);
+
+ // BUG FIX 1 (continued): t1 also uses Date.now() for the same reason.
+ const t1 = Date.now();
+ const ms = t1 - t0;
+
+ // ── Post-resolution snapshot: all queries resolved
+ const postResolutionMetrics = await page.evaluate(() => {
+ const mem = (
+ performance as Performance & {
+ memory?: { usedJSHeapSize: number; totalJSHeapSize: number };
+ }
+ ).memory;
+ const nav = performance.getEntriesByType("navigation")[0] as
+ | PerformanceNavigationTiming
+ | undefined;
+ const fcp =
+ performance.getEntriesByName("first-contentful-paint").at(0)
+ ?.startTime ?? null;
+ const longTaskCount =
+ (window as Window & { __longTaskCount?: number }).__longTaskCount ??
+ null;
+ return {
+ domNodeCount: document.querySelectorAll("*").length,
+ loadingEls: document.querySelectorAll('[data-loading="true"]').length,
+ gridItemsRendered:
+ document.querySelectorAll(".react-grid-item").length,
+ jsHeapUsedMb: mem
+ ? +(mem.usedJSHeapSize / 1_048_576).toFixed(1)
+ : null,
+ ttfbMs: nav ? +(nav.responseStart - nav.startTime).toFixed(1) : null,
+ fcpMs: fcp !== null ? +fcp.toFixed(1) : null,
+ longTaskCount,
+ };
+ });
+
+ // ── Report ────────────────────────────────────────────────────────
+ console.log(`\n=== Large Dashboard (${WIDGET_COUNT} widgets) ===`);
+ console.log(` Full load time : ${ms.toFixed(1)} ms`);
+ console.log(
+ ` TTFB : ${postResolutionMetrics.ttfbMs ?? "n/a"} ms`,
+ );
+ console.log(
+ ` First Contentful Paint : ${postResolutionMetrics.fcpMs ?? "n/a"} ms`,
+ );
+ console.log(
+ ` DOM nodes : ${postResolutionMetrics.domNodeCount}`,
+ );
+ console.log(
+ ` Grid items rendered : ${postResolutionMetrics.gridItemsRendered}`,
+ );
+ // loadingEls: the key pre→post delta for single-value widgets.
+ // DOM node count stays flat because a skeleton and a rendered value have
+ // identical complexity. loadingEls going 100→0 confirms all resolved.
+ console.log(
+ ` Loading widgets (pre) : ${preResolutionMetrics.loadingEls} → (post) ${postResolutionMetrics.loadingEls}`,
+ );
+ if (preResolutionMetrics.jsHeapUsedMb !== null) {
+ console.log(
+ ` JS heap used : ${preResolutionMetrics.jsHeapUsedMb} MB → ${postResolutionMetrics.jsHeapUsedMb} MB`,
+ );
+ }
+ if (postResolutionMetrics.longTaskCount !== null) {
+ console.log(
+ ` Long tasks (>50 ms) : ${postResolutionMetrics.longTaskCount}`,
+ );
+ }
+ console.log("");
+
+ // ── Thresholds ────────────────────────────────────────────────────
+ expect(
+ ms,
+ `${WIDGET_COUNT}-widget dashboard exceeded 30 000 ms threshold`,
+ ).toBeLessThan(30_000);
+
+ // All widget cards must have rendered and resolved
+ expect(
+ postResolutionMetrics.gridItemsRendered,
+ `Expected ${WIDGET_COUNT} grid items, got ${postResolutionMetrics.gridItemsRendered}`,
+ ).toBe(WIDGET_COUNT);
+
+ expect(
+ postResolutionMetrics.loadingEls,
+ "Some widgets still in loading state after timeout",
+ ).toBe(0);
+ } finally {
+ // ── 4. Cleanup — runs even when the test fails ───────────────────────
+ await page.request.delete(`/api/dashboards/${dashboardId}`);
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Performance — 10k-row dataset
+// ---------------------------------------------------------------------------
+
+const FIRST_ROW_BUDGET_MS = 10_000;
+const SCROLL_READY_BUDGET_MS = 15_000;
+
+test.describe("Performance — 10k-row dataset", () => {
+ test("table renders 10k rows within performance budget", async ({
+ page,
+ authPage,
+ }) => {
+ test.setTimeout(120_000);
+ await authPage.login(ALICE.email, ALICE.password);
+
+ const createRes = await page.request.post("/api/dashboards", {
+ data: { name: `10k Rows ${Date.now()}` },
+ });
+ const { id: dashboardId } = (await createRes.json()).data;
+
+ await page.request.put(`/api/dashboards/${dashboardId}`, {
+ data: {
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query:
+ "UNWIND range(1, 10000) AS i RETURN i AS row_id, 'Item ' + i AS name, i * 1.5 AS value",
+ settings: {
+ title: "10k Rows",
+ chartOptions: {
+ enableSorting: true,
+ enablePagination: false,
+ },
+ },
+ },
+ ],
+ gridLayout: [{ i: "w1", x: 0, y: 0, w: 12, h: 8 }],
+ },
+ ],
+ },
+ },
+ });
+
+ try {
+ const t0 = Date.now();
+ await page.goto(`/${dashboardId}`);
+
+ // Wait for the first table row to render
+ await expect(page.locator("table tbody tr").first()).toBeVisible({
+ timeout: 30_000,
+ });
+ const firstRowMs = Date.now() - t0;
+ console.log(` 10k rows — first row visible: ${firstRowMs} ms`);
+ expect(
+ firstRowMs,
+ `First row exceeded ${FIRST_ROW_BUDGET_MS} ms budget`,
+ ).toBeLessThan(FIRST_ROW_BUDGET_MS);
+
+ // Verify loading skeleton is gone (scroll-ready)
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-loading="true"]').length === 0,
+ { timeout: SCROLL_READY_BUDGET_MS },
+ );
+ const scrollReadyMs = Date.now() - t0;
+ console.log(` 10k rows — scroll-ready: ${scrollReadyMs} ms`);
+
+ // Row cap limits to ~5000 rows — verify the table rendered a large dataset
+ const renderedRows = await page.locator("table tbody tr").count();
+ console.log(` 10k rows — DOM rows rendered: ${renderedRows}`);
+ expect(
+ renderedRows,
+ `Expected substantial rows (>100), got ${renderedRows}`,
+ ).toBeGreaterThan(100);
+
+ // Column sort should complete without hang
+ const sortHeader = page.locator("table thead th").first();
+ await sortHeader.click();
+ await expect(page.locator("table tbody tr").first()).toBeVisible({
+ timeout: 5_000,
+ });
+ } finally {
+ await page.request.delete(`/api/dashboards/${dashboardId}`);
+ }
+ });
+});
diff --git a/app/e2e/query-safety.spec.ts b/app/e2e/query-safety.spec.ts
new file mode 100644
index 000000000..0c4bb3c1e
--- /dev/null
+++ b/app/e2e/query-safety.spec.ts
@@ -0,0 +1,365 @@
+import { test, expect, ALICE, createTestDashboard } from "./fixtures";
+import type { APIRequestContext } from "@playwright/test";
+
+/**
+ * Covers issue #480 — query safety nets (timeout + row cap + error UX).
+ *
+ * Seven tests, split by verification strategy:
+ *
+ * 1. PG query timeout — API-only (page.request.post)
+ * 2. Cypher query timeout — API-only (needs APOC, enabled in global-setup)
+ * 3. PG row cap — UI + API (banner + meta.truncated)
+ * 4. Cypher row cap — UI + API
+ * 5. Empty result "No data" — UI (EmptyState)
+ * 6. SQL syntax error — API-only
+ * 7. Cypher syntax error — API-only
+ *
+ * Findings that shape these tests (documented in the PR body):
+ *
+ * 1. Default query timeout is 2000 ms, not 30s. See
+ * connection/src/generalized/interfaces.ts:84 — the CLAUDE.md claim of
+ * 30s is stale. Tests use the real 2s default.
+ *
+ * 2. Row cap is 5000 by default, user-configurable per connection via
+ * `credentials.maxRows` (#499 fix). The driver signals truncation by
+ * calling `setStatus(COMPLETE_TRUNCATED)`, which the query-executor
+ * captures into `truncated: true` on its return value. The API route
+ * forwards both `truncated` and the effective `rowLimit` into meta,
+ * and the UI banner renders "Showing first N rows" with the dynamic
+ * value. Test 3 verifies the default, test 4b verifies a per-connection
+ * override is honored.
+ *
+ * 3. The empty-state card header reads "No results", not "No data". The
+ * exploration agent misread card-container.tsx earlier.
+ *
+ * 4. APOC is required for the Cypher timeout test. global-setup.ts
+ * enables NEO4J_PLUGINS=["apoc"] so `apoc.util.sleep` is available.
+ */
+
+const PG_CONNECTION_ID = "conn-pg-001";
+const NEO4J_CONNECTION_ID = "conn-neo4j-001";
+
+/** Helper: create a dashboard with a single table widget that runs `query`. */
+async function createSingleTableDashboard(
+ request: APIRequestContext,
+ name: string,
+ connectionId: string,
+ query: string,
+) {
+ const { id, cleanup } = await createTestDashboard(request, name);
+ const putRes = await request.put(`/api/dashboards/${id}`, {
+ data: {
+ layoutJson: {
+ version: 2 as const,
+ pages: [
+ {
+ id: "page-1",
+ title: "Main",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId,
+ query,
+ settings: { title: name },
+ },
+ ],
+ gridLayout: [{ i: "w1", x: 0, y: 0, w: 12, h: 8 }],
+ },
+ ],
+ },
+ },
+ });
+ if (!putRes.ok()) {
+ throw new Error(`PUT dashboard failed: ${putRes.status()}`);
+ }
+ return { id, cleanup };
+}
+
+test.describe("Query safety nets — timeout + row cap + error UX", () => {
+ test.describe.configure({ timeout: 60_000 });
+
+ test.beforeEach(async ({ authPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 1. PostgreSQL query timeout
+ // ─────────────────────────────────────────────────────────────────────────
+ test("PG query exceeding the timeout returns a user-facing error", async ({
+ page,
+ }) => {
+ const t0 = Date.now();
+ const res = await page.request.post("/api/query", {
+ data: {
+ connectionId: PG_CONNECTION_ID,
+ // pg_sleep(3) far exceeds the 2s driver-level statement timeout.
+ query: "SELECT pg_sleep(3)",
+ },
+ });
+ const elapsed = Date.now() - t0;
+
+ // The driver/route must fail fast, not hang the full 3s. Allow some
+ // overhead for round-trip + error handling — 5s is a generous ceiling.
+ expect(elapsed).toBeLessThan(5_000);
+ expect(res.status()).toBe(500);
+
+ const body = await res.json();
+ expect(body.error?.message).toBeTruthy();
+ // The message should be a human string, not a stack trace.
+ expect(body.error?.message).not.toMatch(/\s+at\s.+:\d+:\d+/);
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 2. Cypher query timeout
+ // ─────────────────────────────────────────────────────────────────────────
+ //
+ // Important: `apoc.util.sleep` is NOT a reliable way to trigger a Neo4j
+ // transaction timeout. It's a pure Java Thread.sleep that runs inside
+ // the transaction but never yields back to the driver's guard points,
+ // so the 2s timeout can't interrupt it — the sleep completes, the
+ // transaction commits, and the request returns 200. Observed as flakiness
+ // in the first iteration of this spec.
+ //
+ // Instead, we use a compute-heavy query that hits guard points between
+ // iterations. Neo4j's managed transaction timeout is checked between
+ // each operator fetch, so a long UNWIND pipeline with work inside each
+ // iteration gets aborted cleanly.
+ test("Cypher query exceeding the timeout returns a user-facing error", async ({
+ page,
+ }) => {
+ const t0 = Date.now();
+ const res = await page.request.post("/api/query", {
+ data: {
+ connectionId: NEO4J_CONNECTION_ID,
+ query:
+ "UNWIND range(1, 5000000) AS x " +
+ "UNWIND range(1, 1000) AS y " +
+ "RETURN count(x + y) AS c",
+ },
+ });
+ const elapsed = Date.now() - t0;
+
+ // Cypher's cancel cycle plus APOC plugin overhead is slower than PG —
+ // allow up to 10s for the driver to abort and the route to respond.
+ expect(elapsed).toBeLessThan(10_000);
+ expect(res.status()).toBe(500);
+
+ const body = await res.json();
+ expect(body.error?.message).toBeTruthy();
+ expect(body.error?.message).not.toMatch(/\s+at\s.+:\d+:\d+/);
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 3. PostgreSQL row cap — driver signal reaches meta.truncated + banner
+ // ─────────────────────────────────────────────────────────────────────────
+ //
+ // After #499, the PG connector's COMPLETE_TRUNCATED status flows through
+ // the query-executor's setStatus handler into the API response, so both
+ // meta.truncated and meta.rowLimit are populated and the widget renders
+ // the "Showing first N rows" banner.
+ test("PG row cap propagates driver truncation signal to API and widget banner", async ({
+ page,
+ }) => {
+ // API-level assertion first — seeded conn-pg-001 has no maxRows
+ // override, so the effective cap is DEFAULT_MAX_ROWS (5000).
+ const apiRes = await page.request.post("/api/query", {
+ data: {
+ connectionId: PG_CONNECTION_ID,
+ query: "SELECT generate_series(1, 15000) AS id",
+ },
+ });
+ expect(apiRes.status()).toBe(200);
+ const body = await apiRes.json();
+
+ expect(Array.isArray(body.data?.data)).toBe(true);
+ expect((body.data?.data as unknown[]).length).toBe(5_000);
+ expect(body.meta?.truncated).toBe(true);
+ expect(body.meta?.rowLimit).toBe(5000);
+
+ // UI-level assertion: create a dashboard that runs the same query
+ // and verify the banner renders with the correct dynamic text.
+ const { id, cleanup } = await createSingleTableDashboard(
+ page.request,
+ `pg-row-cap ${Date.now()}`,
+ PG_CONNECTION_ID,
+ "SELECT generate_series(1, 15000) AS id",
+ );
+ try {
+ await page.goto(`/${id}`);
+ await expect(
+ page.getByText(
+ /Showing first 5,000 rows\. Refine your query to see all results\./,
+ ),
+ ).toBeVisible({ timeout: 20_000 });
+ } finally {
+ await cleanup();
+ }
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 4. Cypher row cap — same behavior via Neo4j driver signal
+ // ─────────────────────────────────────────────────────────────────────────
+ test("Cypher row cap propagates driver truncation signal to API and widget banner", async ({
+ page,
+ }) => {
+ const apiRes = await page.request.post("/api/query", {
+ data: {
+ connectionId: NEO4J_CONNECTION_ID,
+ query: "UNWIND range(1, 15000) AS x RETURN x AS id",
+ },
+ });
+ expect(apiRes.status()).toBe(200);
+ const body = await apiRes.json();
+
+ expect(Array.isArray(body.data?.data)).toBe(true);
+ expect((body.data?.data as unknown[]).length).toBe(5_000);
+ expect(body.meta?.truncated).toBe(true);
+ expect(body.meta?.rowLimit).toBe(5000);
+
+ const { id, cleanup } = await createSingleTableDashboard(
+ page.request,
+ `cypher-row-cap ${Date.now()}`,
+ NEO4J_CONNECTION_ID,
+ "UNWIND range(1, 15000) AS x RETURN x AS id",
+ );
+ try {
+ await page.goto(`/${id}`);
+ await expect(
+ page.getByText(
+ /Showing first 5,000 rows\. Refine your query to see all results\./,
+ ),
+ ).toBeVisible({ timeout: 20_000 });
+ } finally {
+ await cleanup();
+ }
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 4b. Per-connection maxRows override
+ // ─────────────────────────────────────────────────────────────────────────
+ //
+ // Creators can raise (or lower) the cap on a per-connection basis via
+ // Advanced Settings > Max Rows per Query. This test creates a PG
+ // connection with maxRows=1000 and verifies the driver honors it — both
+ // the row count and the banner should reflect the custom value.
+ test("per-connection maxRows override is honored by driver + banner", async ({
+ page,
+ }) => {
+ // Create a fresh PG connection with an explicit maxRows cap.
+ const createRes = await page.request.post("/api/connections", {
+ data: {
+ name: `maxrows-override ${Date.now()}`,
+ type: "postgresql",
+ config: {
+ uri: `postgresql://localhost:${process.env.TEST_PG_PORT ?? "5432"}`,
+ username: "neoboard",
+ password: "neoboard",
+ database: "movies",
+ maxRows: 1000,
+ },
+ },
+ });
+ expect(createRes.status()).toBe(201);
+ const connId = (await createRes.json()).data.id as string;
+
+ try {
+ // API-level: effective cap should be 1000, not the 5000 default.
+ const apiRes = await page.request.post("/api/query", {
+ data: {
+ connectionId: connId,
+ query: "SELECT generate_series(1, 5000) AS id",
+ },
+ });
+ expect(apiRes.status()).toBe(200);
+ const body = await apiRes.json();
+ expect((body.data?.data as unknown[]).length).toBe(1_000);
+ expect(body.meta?.truncated).toBe(true);
+ expect(body.meta?.rowLimit).toBe(1000);
+
+ // UI-level: banner should render with the override value.
+ const { id, cleanup } = await createSingleTableDashboard(
+ page.request,
+ `pg-override ${Date.now()}`,
+ connId,
+ "SELECT generate_series(1, 5000) AS id",
+ );
+ try {
+ await page.goto(`/${id}`);
+ await expect(page.getByText(/Showing first 1,000 rows\./)).toBeVisible({
+ timeout: 20_000,
+ });
+ } finally {
+ await cleanup();
+ }
+ } finally {
+ await page.request.delete(`/api/connections/${connId}`);
+ }
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 5. Empty result set shows "No data" empty state
+ // ─────────────────────────────────────────────────────────────────────────
+ test("empty result set renders the No data empty state, not an error", async ({
+ page,
+ }) => {
+ const { id, cleanup } = await createSingleTableDashboard(
+ page.request,
+ `empty-result ${Date.now()}`,
+ NEO4J_CONNECTION_ID,
+ // A label that provably does not exist in the seeded movies DB.
+ "MATCH (n:ThisLabelDoesNotExist) RETURN n",
+ );
+
+ try {
+ await page.goto(`/${id}`);
+ // The empty state header text is "No results" (not "No data" — the
+ // exploration agent misread the card-container source earlier).
+ await expect(page.getByText("No results", { exact: true })).toBeVisible({
+ timeout: 20_000,
+ });
+ // Critically: the widget must not show an error state.
+ await expect(page.getByText(/query.*failed/i)).not.toBeVisible();
+ } finally {
+ await cleanup();
+ }
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 6. SQL syntax error → 500 + user-facing message
+ // ─────────────────────────────────────────────────────────────────────────
+ test("SQL syntax error returns a user-facing message, not a stack trace", async ({
+ page,
+ }) => {
+ const res = await page.request.post("/api/query", {
+ data: {
+ connectionId: PG_CONNECTION_ID,
+ query: "SELEKT * FROM movies",
+ },
+ });
+ expect(res.status()).toBe(500);
+
+ const body = await res.json();
+ expect(body.error?.message).toBeTruthy();
+ expect(body.error?.message).not.toMatch(/\s+at\s.+:\d+:\d+/);
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 7. Cypher syntax error → 500 + user-facing message
+ // ─────────────────────────────────────────────────────────────────────────
+ test("Cypher syntax error returns a user-facing message, not a stack trace", async ({
+ page,
+ }) => {
+ const res = await page.request.post("/api/query", {
+ data: {
+ connectionId: NEO4J_CONNECTION_ID,
+ query: "MATCH MATCH MATCH",
+ },
+ });
+ expect(res.status()).toBe(500);
+
+ const body = await res.json();
+ expect(body.error?.message).toBeTruthy();
+ expect(body.error?.message).not.toMatch(/\s+at\s.+:\d+:\d+/);
+ });
+});
diff --git a/app/e2e/responsive.spec.ts b/app/e2e/responsive.spec.ts
new file mode 100644
index 000000000..3b92510ec
--- /dev/null
+++ b/app/e2e/responsive.spec.ts
@@ -0,0 +1,100 @@
+import { test, expect, ALICE } from "./fixtures";
+
+test.describe("Responsive — mobile viewport", () => {
+ test.use({ viewport: { width: 375, height: 812 } });
+
+ test.beforeEach(async ({ authPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ });
+
+ test("dashboard list should render in single column on mobile", async ({
+ page,
+ }) => {
+ await expect(
+ page.getByText("Movie Analytics", { exact: true }),
+ ).toBeVisible({
+ timeout: 15_000,
+ });
+ // Verify grid renders single column at mobile width
+ const grid = page.locator(".grid").first();
+ await expect(grid).toBeVisible();
+ const columns = await grid.evaluate(
+ (el) => getComputedStyle(el).gridTemplateColumns,
+ );
+ // Single column = one value (no spaces)
+ expect(columns.trim().split(/\s+/).length).toBe(1);
+ });
+});
+
+test.describe("Responsive — mobile login (unauthenticated)", () => {
+ test.use({ viewport: { width: 375, height: 812 } });
+
+ test("login page should render correctly on mobile", async ({ page }) => {
+ await page.goto("/login");
+ await expect(page.getByText("NeoBoard")).toBeVisible();
+ await expect(page.getByLabel("Email")).toBeVisible();
+ await expect(page.getByLabel("Password")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
+ });
+});
+
+test.describe("Responsive — tablet viewport", () => {
+ test.use({ viewport: { width: 768, height: 1024 } });
+
+ test.beforeEach(async ({ authPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ });
+
+ test("dashboard list should render on tablet", async ({ page }) => {
+ await expect(
+ page.getByText("Movie Analytics", { exact: true }),
+ ).toBeVisible({
+ timeout: 15_000,
+ });
+ const grid = page.locator(".grid").first();
+ await expect(grid).toBeVisible();
+ const columns = await grid.evaluate(
+ (el) => getComputedStyle(el).gridTemplateColumns,
+ );
+ // Tablet (768px) hits sm breakpoint (640px) → 2 columns
+ expect(columns.trim().split(/\s+/).length).toBe(2);
+ });
+
+ test("connections page should render on tablet", async ({
+ sidebarPage,
+ page,
+ }) => {
+ await sidebarPage.navigateTo("Connections");
+ await expect(
+ page.getByRole("heading", { level: 1, name: "Connections" }),
+ ).toBeVisible({ timeout: 10_000 });
+ await expect(
+ page.getByRole("button", { name: "Add Connection" }),
+ ).toBeVisible();
+ });
+});
+
+test.describe("Responsive — wide desktop viewport", () => {
+ test.use({ viewport: { width: 1920, height: 1080 } });
+
+ test.beforeEach(async ({ authPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ });
+
+ test("dashboard list should render in three columns on wide desktop", async ({
+ page,
+ }) => {
+ await expect(
+ page.getByText("Movie Analytics", { exact: true }),
+ ).toBeVisible({
+ timeout: 15_000,
+ });
+ const grid = page.locator(".grid").first();
+ await expect(grid).toBeVisible();
+ const columns = await grid.evaluate(
+ (el) => getComputedStyle(el).gridTemplateColumns,
+ );
+ // Wide desktop (1920px) hits lg breakpoint (1024px) → 3 columns
+ expect(columns.trim().split(/\s+/).length).toBe(3);
+ });
+});
diff --git a/app/e2e/settings-profile.spec.ts b/app/e2e/settings-profile.spec.ts
new file mode 100644
index 000000000..615c5a4fb
--- /dev/null
+++ b/app/e2e/settings-profile.spec.ts
@@ -0,0 +1,119 @@
+import { test, expect, ALICE } from "./fixtures";
+
+test.describe("Settings — Profile", () => {
+ test.beforeEach(async ({ authPage, sidebarPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ await sidebarPage.navigateTo("Settings");
+ });
+
+ test("settings page shows Profile and API Keys tabs", async ({ page }) => {
+ await expect(page.getByRole("button", { name: "Profile" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "API Keys" })).toBeVisible();
+ });
+
+ test("sidebar navigates to profile by default", async ({ page }) => {
+ await expect(page).toHaveURL(/\/settings\/profile/);
+ });
+
+ test("profile page shows account info", async ({ page }) => {
+ await expect(page.getByText(ALICE.email)).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByText("Write Access")).toBeVisible();
+ await expect(page.getByText("Member Since")).toBeVisible();
+ });
+
+ test("can update display name", async ({ page }) => {
+ const nameInput = page.locator("#profile-name");
+ await expect(nameInput).toBeVisible({ timeout: 10_000 });
+
+ // Save the original name to restore later
+ const originalName = await nameInput.inputValue();
+ const newName = `Alice ${Date.now()}`;
+
+ await nameInput.clear();
+ await nameInput.fill(newName);
+ await page.getByRole("button", { name: "Save" }).click();
+
+ // Verify the name was saved — button should become disabled (name matches profile)
+ await expect(page.getByRole("button", { name: "Save" })).toBeDisabled({
+ timeout: 5_000,
+ });
+
+ // Restore original name
+ await nameInput.clear();
+ await nameInput.fill(originalName || "Alice");
+ await page.getByRole("button", { name: "Save" }).click();
+ await expect(page.getByRole("button", { name: "Save" })).toBeDisabled({
+ timeout: 5_000,
+ });
+ });
+
+ test("password change shows error for wrong current password", async ({
+ page,
+ }) => {
+ await page.locator("#current-password").fill("wrongpassword");
+ await page.locator("#new-password").fill("newpass123");
+ await page.locator("#confirm-password").fill("newpass123");
+ await page.getByRole("button", { name: "Change Password" }).click();
+ await expect(page.getByText("Current password is incorrect")).toBeVisible({
+ timeout: 5_000,
+ });
+ });
+
+ test("password change shows error for mismatched passwords", async ({
+ page,
+ }) => {
+ await page.locator("#current-password").fill(ALICE.password);
+ await page.locator("#new-password").fill("newpass123");
+ await page.locator("#confirm-password").fill("different123");
+ await page.getByRole("button", { name: "Change Password" }).click();
+ await expect(page.getByText("New passwords do not match")).toBeVisible();
+ });
+
+ test("successful password change shows success and keeps user logged in", async ({
+ page,
+ }) => {
+ const tempPassword = "tempPass999";
+
+ // Change to temporary password
+ await page.locator("#current-password").fill(ALICE.password);
+ await page.locator("#new-password").fill(tempPassword);
+ await page.locator("#confirm-password").fill(tempPassword);
+ await page.getByRole("button", { name: "Change Password" }).click();
+ await expect(page.getByText("Password changed successfully")).toBeVisible({
+ timeout: 5_000,
+ });
+
+ // User should still be on the settings page (not kicked out)
+ await expect(page).toHaveURL(/\/settings\/profile/);
+ await expect(page.getByText("Account", { exact: true })).toBeVisible();
+
+ // Change back to original password
+ await page.locator("#current-password").fill(tempPassword);
+ await page.locator("#new-password").fill(ALICE.password);
+ await page.locator("#confirm-password").fill(ALICE.password);
+ await page.getByRole("button", { name: "Change Password" }).click();
+ await expect(page.getByText("Password changed successfully")).toBeVisible({
+ timeout: 5_000,
+ });
+ });
+
+ test("can switch to API Keys tab", async ({ page }) => {
+ await page.getByRole("button", { name: "API Keys" }).click();
+ await expect(page).toHaveURL(/\/settings\/api-keys/);
+ await expect(
+ page.getByRole("heading", { name: "API Keys", exact: true }),
+ ).toBeVisible();
+ });
+});
+
+test.describe("Settings — Redirect", () => {
+ test("navigating to /settings redirects to /settings/profile", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ await page.goto("/settings");
+ await page.waitForLoadState("networkidle");
+ await expect(page).toHaveURL(/\/settings\/profile/);
+ });
+});
diff --git a/app/e2e/sharing-permissions.spec.ts b/app/e2e/sharing-permissions.spec.ts
new file mode 100644
index 000000000..4dbc0a4cf
--- /dev/null
+++ b/app/e2e/sharing-permissions.spec.ts
@@ -0,0 +1,329 @@
+import {
+ test,
+ expect,
+ ALICE,
+ BOB,
+ CAROL,
+ DAVE,
+ createTestDashboard,
+} from "./fixtures";
+import { AuthPage } from "./pages/auth";
+import type { Browser, Page } from "@playwright/test";
+
+/**
+ * Covers issue #477 — dashboard sharing CRUD + full permission matrix.
+ *
+ * Note: the "Sharing" button in the edit toolbar is currently gated on
+ * `isAdmin` (app/src/app/(dashboard)/[id]/edit/page.tsx), so UI-driven share
+ * tests use Alice (admin) as the sharer. The API layer (requireShareAccess)
+ * also allows the owner, but since the UI doesn't expose that path we only
+ * exercise the admin flow here.
+ *
+ * Login robustness: AuthPage.login handles the pre-hydration submit race
+ * upstream (see app/e2e/pages/auth.ts). For tests that create a fresh
+ * browser context, we instantiate a new AuthPage(page) rather than
+ * duplicating the retry loop locally.
+ */
+
+async function loginAs(
+ browser: Browser,
+ email: string,
+ password: string,
+): Promise<{ page: Page; close: () => Promise }> {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await new AuthPage(page).login(email, password);
+ return { page, close: () => context.close() };
+}
+
+async function setupAliceDashboard(page: Page, name: string) {
+ await new AuthPage(page).login(ALICE.email, ALICE.password);
+ return createTestDashboard(page.request, name);
+}
+
+async function openSharingPanel(page: Page, dashboardId: string) {
+ await page.goto(`/${dashboardId}/edit`);
+ await page.waitForURL(/\/edit/, { timeout: 15_000 });
+ await page.getByRole("button", { name: "Sharing" }).click();
+ await expect(page.getByText("People")).toBeVisible({ timeout: 10_000 });
+}
+
+test.describe("Dashboard sharing — CRUD + permission matrix", () => {
+ // AuthPage.login may retry up to 3 times on the pre-hydration submit race,
+ // so an unlucky run can spend 10–20s on login alone. Combined with
+ // multi-context tests and per-test cleanup, the default 30s is too tight.
+ // 60s gives enough headroom without masking real regressions.
+ test.describe.configure({ timeout: 60_000 });
+
+ test("1. share dashboard with user as viewer (happy path)", async ({
+ page,
+ }) => {
+ const { id, cleanup } = await setupAliceDashboard(
+ page,
+ `Share Test 1 ${Date.now()}`,
+ );
+ try {
+ await openSharingPanel(page, id);
+ await page.locator("#assign-email").fill(DAVE.email);
+ // Default role is "viewer", no need to change it
+ await page.getByRole("button", { name: "Assign" }).click();
+
+ await expect(page.getByText("Dave Demo")).toBeVisible({ timeout: 5_000 });
+ await expect(
+ page.locator(`[aria-label="Remove ${DAVE.email}"]`),
+ ).toBeVisible();
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("2. share dashboard with user as editor", async ({ page }) => {
+ const { id, cleanup } = await setupAliceDashboard(
+ page,
+ `Share Test 2 ${Date.now()}`,
+ );
+ try {
+ await openSharingPanel(page, id);
+ await page.locator("#assign-email").fill(DAVE.email);
+ await page.locator("#assign-role").click();
+ await page.getByRole("option", { name: "Editor" }).click();
+ await page.getByRole("button", { name: "Assign" }).click();
+
+ await expect(page.getByText("Dave Demo")).toBeVisible({ timeout: 5_000 });
+ // Role label is rendered with a `capitalize` class; the raw text is "editor".
+ await expect(page.getByText("editor", { exact: true })).toBeVisible();
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("3. update existing share role without duplicating the row", async ({
+ page,
+ }) => {
+ const { id, cleanup } = await setupAliceDashboard(
+ page,
+ `Share Test 3 ${Date.now()}`,
+ );
+ try {
+ // Seed viewer share via API
+ const seedRes = await page.request.post(`/api/dashboards/${id}/share`, {
+ data: { email: DAVE.email, role: "viewer" },
+ });
+ expect(seedRes.status()).toBe(201);
+
+ await openSharingPanel(page, id);
+ await expect(page.getByText("viewer", { exact: true })).toBeVisible();
+
+ // Upsert to editor via UI
+ await page.locator("#assign-email").fill(DAVE.email);
+ await page.locator("#assign-role").click();
+ await page.getByRole("option", { name: "Editor" }).click();
+ await page.getByRole("button", { name: "Assign" }).click();
+
+ await expect(page.getByText("editor", { exact: true })).toBeVisible({
+ timeout: 5_000,
+ });
+ await expect(page.getByText("viewer", { exact: true })).not.toBeVisible();
+ await expect(page.getByText("Dave Demo")).toHaveCount(1);
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("4. revoke share removes user from the list", async ({ page }) => {
+ const { id, cleanup } = await setupAliceDashboard(
+ page,
+ `Share Test 4 ${Date.now()}`,
+ );
+ try {
+ await page.request.post(`/api/dashboards/${id}/share`, {
+ data: { email: DAVE.email, role: "viewer" },
+ });
+
+ await openSharingPanel(page, id);
+ await expect(page.getByText("Dave Demo")).toBeVisible();
+
+ await page.locator(`[aria-label="Remove ${DAVE.email}"]`).click();
+ await expect(page.getByText("Dave Demo")).not.toBeVisible({
+ timeout: 5_000,
+ });
+ await expect(page.getByText("No users assigned yet.")).toBeVisible();
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("5. share with non-existent email shows error", async ({ page }) => {
+ const { id, cleanup } = await setupAliceDashboard(
+ page,
+ `Share Test 5 ${Date.now()}`,
+ );
+ try {
+ await openSharingPanel(page, id);
+ await page.locator("#assign-email").fill("ghost@example.com");
+ await page.getByRole("button", { name: "Assign" }).click();
+
+ await expect(page.getByText(/user not found/i)).toBeVisible({
+ timeout: 5_000,
+ });
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("6. share with self shows error", async ({ page }) => {
+ const { id, cleanup } = await setupAliceDashboard(
+ page,
+ `Share Test 6 ${Date.now()}`,
+ );
+ try {
+ await openSharingPanel(page, id);
+ await page.locator("#assign-email").fill(ALICE.email);
+ await page.getByRole("button", { name: "Assign" }).click();
+
+ await expect(page.getByText(/cannot share with yourself/i)).toBeVisible({
+ timeout: 5_000,
+ });
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("7. recipient sees shared dashboard in their list", async ({
+ page,
+ browser,
+ }) => {
+ const name = `Share Test 7 ${Date.now()}`;
+ const { id, cleanup } = await setupAliceDashboard(page, name);
+ try {
+ await page.request.post(`/api/dashboards/${id}/share`, {
+ data: { email: DAVE.email, role: "viewer" },
+ });
+
+ const dave = await loginAs(browser, DAVE.email, DAVE.password);
+ try {
+ await expect(dave.page.getByText(name)).toBeVisible({
+ timeout: 10_000,
+ });
+ } finally {
+ await dave.close();
+ }
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("8. viewer cannot edit: Edit button hidden and direct PUT returns 404", async ({
+ page,
+ browser,
+ }) => {
+ const { id, cleanup } = await setupAliceDashboard(
+ page,
+ `Share Test 8 ${Date.now()}`,
+ );
+ try {
+ await page.request.post(`/api/dashboards/${id}/share`, {
+ data: { email: DAVE.email, role: "viewer" },
+ });
+
+ const dave = await loginAs(browser, DAVE.email, DAVE.password);
+ try {
+ await dave.page.goto(`/${id}`);
+ await expect(
+ dave.page.getByRole("button", { name: "Edit", exact: true }),
+ ).not.toBeVisible();
+
+ // Direct API: PUT returns 404 for non-editor (defence-in-depth:
+ // don't leak existence of dashboards the caller can't edit).
+ const putRes = await dave.page.request.put(`/api/dashboards/${id}`, {
+ data: { name: "Hijacked" },
+ });
+ expect(putRes.status()).toBe(404);
+ } finally {
+ await dave.close();
+ }
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("9. editor can edit widgets but cannot delete the dashboard", async ({
+ page,
+ browser,
+ }) => {
+ const name = `Share Test 9 ${Date.now()}`;
+ const { id, cleanup } = await setupAliceDashboard(page, name);
+ try {
+ await page.request.post(`/api/dashboards/${id}/share`, {
+ data: { email: DAVE.email, role: "editor" },
+ });
+
+ const dave = await loginAs(browser, DAVE.email, DAVE.password);
+ try {
+ // Editor PUT (rename) succeeds.
+ const putRes = await dave.page.request.put(`/api/dashboards/${id}`, {
+ data: { name: `${name} — edited` },
+ });
+ expect(putRes.status()).toBe(200);
+
+ // Editor DELETE fails — only the owner (or admin) can delete.
+ // The route returns 404 rather than 403 to avoid leaking existence.
+ const delRes = await dave.page.request.delete(`/api/dashboards/${id}`);
+ expect(delRes.status()).toBe(404);
+ } finally {
+ await dave.close();
+ }
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("10. admin bypasses per-dashboard ACL on dashboards owned by others", async ({
+ page,
+ browser,
+ }) => {
+ // Bob owns the dashboard — Alice (admin) should still be able to read
+ // and modify it without any explicit share.
+ await new AuthPage(page).login(BOB.email, BOB.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Share Test 10 ${Date.now()}`,
+ );
+ try {
+ const alice = await loginAs(browser, ALICE.email, ALICE.password);
+ try {
+ const getRes = await alice.page.request.get(`/api/dashboards/${id}`);
+ expect(getRes.status()).toBe(200);
+ const body = await getRes.json();
+ expect(body.data.role).toBe("admin");
+
+ const putRes = await alice.page.request.put(`/api/dashboards/${id}`, {
+ data: { name: "Admin Bypass Rename" },
+ });
+ expect(putRes.status()).toBe(200);
+ } finally {
+ await alice.close();
+ }
+ } finally {
+ await cleanup();
+ }
+ });
+
+ test("11. reader cannot create dashboards (UI hidden + API returns 403)", async ({
+ page,
+ }) => {
+ await new AuthPage(page).login(CAROL.email, CAROL.password);
+
+ // UI: "New Dashboard" CTA is gated on canCreate (admin|creator),
+ // so readers do not see it at all.
+ await expect(
+ page.getByRole("button", { name: /new dashboard/i }),
+ ).not.toBeVisible();
+
+ // API: direct POST is blocked by the canWrite check.
+ const res = await page.request.post("/api/dashboards", {
+ data: { name: "Reader Attempt" },
+ });
+ expect(res.status()).toBe(403);
+ });
+});
diff --git a/app/e2e/sidebar-states.spec.ts b/app/e2e/sidebar-states.spec.ts
new file mode 100644
index 000000000..01af0db66
--- /dev/null
+++ b/app/e2e/sidebar-states.spec.ts
@@ -0,0 +1,30 @@
+import { test, expect, ALICE } from "./fixtures";
+
+test.describe("Sidebar — uncovered states", () => {
+ test.beforeEach(async ({ authPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ });
+
+ test("sidebar should highlight active page", async ({
+ sidebarPage,
+ page,
+ }) => {
+ // On dashboards page, the Dashboards button should be active
+ const dashboardsBtn = sidebarPage.getSidebarItem("Dashboards");
+ await expect(dashboardsBtn).toBeVisible();
+ // Active sidebar items get font-medium class
+ await expect(dashboardsBtn).toHaveClass(/font-medium/);
+
+ // Navigate to Connections — it should become active
+ await sidebarPage.navigateTo("Connections");
+ await expect(page).toHaveURL("/connections");
+ const connectionsBtn = sidebarPage.getSidebarItem("Connections");
+ await expect(connectionsBtn).toHaveClass(/font-medium/);
+
+ // Navigate to Users — it should become active
+ await sidebarPage.navigateTo("Users");
+ await expect(page).toHaveURL("/users");
+ const usersBtn = sidebarPage.getSidebarItem("Users");
+ await expect(usersBtn).toHaveClass(/font-medium/);
+ });
+});
diff --git a/app/e2e/styling-rules.spec.ts b/app/e2e/styling-rules.spec.ts
new file mode 100644
index 000000000..f669ea3dc
--- /dev/null
+++ b/app/e2e/styling-rules.spec.ts
@@ -0,0 +1,281 @@
+import { test, expect, ALICE, createTestDashboard, typeInEditor, getPreview } from "./fixtures";
+
+// ---------------------------------------------------------------------------
+// Styling rules editor — table widget
+// ---------------------------------------------------------------------------
+
+test.describe("Styling rules — table widget", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.beforeEach(async ({ authPage, page }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Styling Rules ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+ });
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ /**
+ * Helper: add a table widget, run a query, and navigate to the Advanced tab.
+ * Returns the scoped dialog locator.
+ */
+ async function addTableAndGoToAdvanced(page: import("@playwright/test").Page) {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select Data Table + Neo4j connection
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option", { name: /Movies Graph/ }).click();
+
+ // Wait for editor to be ready (connection selection may cause re-render)
+ await expect(dialog.locator("[data-testid='codemirror-container']")).toBeVisible({
+ timeout: 5_000,
+ });
+
+ // Query and run
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.title AS title, m.released AS released LIMIT 10",
+ );
+ await expect(dialog.getByTitle("Run query (Ctrl+Enter / \u2318+Enter)")).toBeEnabled({
+ timeout: 10_000,
+ });
+ await dialog.getByTitle("Run query (Ctrl+Enter / \u2318+Enter)").click();
+ await expect(getPreview(dialog)).toBeVisible({ timeout: 15_000 });
+
+ // Navigate to Advanced tab
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+
+ return dialog;
+ }
+
+ test("should enable styling, add a rule, and see rule count", async ({ page }) => {
+ test.setTimeout(60_000);
+ const dialog = await addTableAndGoToAdvanced(page);
+
+ // Enable styling
+ await dialog.getByLabel("Enable rule-based styling").click();
+ await expect(dialog.getByText("No styling rules configured.")).toBeVisible();
+
+ // Open Styling Rules editor
+ await dialog.getByRole("button", { name: "Manage Styling Rules" }).click();
+
+ // Should now show the Styling Rules heading
+ await expect(page.getByRole("heading", { name: "Styling Rules" })).toBeVisible();
+ await expect(page.getByText("No styling rules yet")).toBeVisible();
+
+ // Add a rule
+ await page.getByRole("button", { name: "Add Rule" }).click();
+ await expect(page.getByText("Rule 1")).toBeVisible();
+
+ // Verify default operator is <= and fill value
+ await expect(page.getByText("<= (less or equal)")).toBeVisible();
+
+ // Fill value for the rule — find the value input inside the rule content
+ const valueInput = page.locator("input[type='number']").first();
+ await valueInput.fill("2000");
+
+ // Set color via the text input (not color picker)
+ const colorInput = page.locator("input[placeholder='#3b82f6']").first();
+ await colorInput.fill("#ef4444");
+
+ // Click Done to return to main dialog
+ await page.getByRole("button", { name: "Done" }).click();
+
+ // Should return to the main dialog and show rule count
+ // Navigate back to Advanced tab
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await expect(dialog.getByText("1 styling rule(s) configured.")).toBeVisible();
+ });
+
+ test("should show between operator with two bound inputs", async ({ page }) => {
+ test.setTimeout(60_000);
+ const dialog = await addTableAndGoToAdvanced(page);
+
+ // Enable styling and open editor
+ await dialog.getByLabel("Enable rule-based styling").click();
+ await dialog.getByRole("button", { name: "Manage Styling Rules" }).click();
+
+ // Add a rule
+ await page.getByRole("button", { name: "Add Rule" }).click();
+ await expect(page.getByText("Rule 1")).toBeVisible();
+
+ // Change operator to "between"
+ await page.getByLabel("Operator").click();
+ await page.getByRole("option", { name: "between" }).click();
+
+ // Should show two bound inputs
+ await expect(page.getByText("From (min)")).toBeVisible();
+ await expect(page.getByText("To (max)")).toBeVisible();
+
+ await page.getByRole("button", { name: "Done" }).click();
+ });
+
+ test("should show per-rule column selector for table type", async ({ page }) => {
+ test.setTimeout(60_000);
+ const dialog = await addTableAndGoToAdvanced(page);
+
+ // Enable styling and open editor
+ await dialog.getByLabel("Enable rule-based styling").click();
+ await dialog.getByRole("button", { name: "Manage Styling Rules" }).click();
+
+ // Add a rule so we can see the per-rule column selector
+ await page.getByRole("button", { name: "Add Rule" }).click();
+ // Each rule should have a "Column" field with per-rule column picker
+ await expect(page.getByText("Column")).toBeVisible();
+ await expect(page.getByText("Auto (first numeric)")).toBeVisible();
+
+ await page.getByRole("button", { name: "Done" }).click();
+ });
+
+ test("should delete a rule", async ({ page }) => {
+ test.setTimeout(60_000);
+ const dialog = await addTableAndGoToAdvanced(page);
+
+ // Enable styling and open editor
+ await dialog.getByLabel("Enable rule-based styling").click();
+ await dialog.getByRole("button", { name: "Manage Styling Rules" }).click();
+
+ // Add 2 rules
+ await page.getByRole("button", { name: "Add Rule" }).click();
+ await expect(page.getByText("Rule 1")).toBeVisible();
+ await page.getByRole("button", { name: "Add Rule" }).click();
+ await expect(page.getByText("Rule 2")).toBeVisible();
+
+ // Delete Rule 1
+ await page.getByRole("button", { name: "Delete rule 1" }).click();
+
+ // Should now show only 1 rule
+ await expect(page.getByText("Rule 2")).not.toBeVisible();
+ await expect(page.getByText("Rule 1")).toBeVisible(); // Remaining rule re-indexed
+
+ // Click Done
+ await page.getByRole("button", { name: "Done" }).click();
+
+ // Verify rule count
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await expect(dialog.getByText("1 styling rule(s) configured.")).toBeVisible();
+ });
+
+ test("should save widget with styling and verify colored rows", async ({ page }) => {
+ test.setTimeout(60_000);
+ const dialog = await addTableAndGoToAdvanced(page);
+
+ // Enable styling and open editor
+ await dialog.getByLabel("Enable rule-based styling").click();
+ await dialog.getByRole("button", { name: "Manage Styling Rules" }).click();
+
+ // Add a rule: <= 1999 -> red
+ await page.getByRole("button", { name: "Add Rule" }).click();
+ const valueInput = page.locator("input[type='number']").first();
+ await valueInput.fill("1999");
+ const colorInput = page.locator("input[placeholder='#3b82f6']").first();
+ await colorInput.fill("#ef4444");
+
+ // Done
+ await page.getByRole("button", { name: "Done" }).click();
+
+ // Add Widget
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog).not.toBeVisible({ timeout: 5_000 });
+
+ // Save dashboard
+ await page.getByRole("button", { name: "Save" }).click();
+ await expect(page.getByRole("button", { name: "Save" })).toBeEnabled({ timeout: 10_000 });
+
+ // Navigate to view mode
+ await page.getByRole("button", { name: "Back" }).click();
+ await page.waitForURL(/\/[\w-]+$/, { timeout: 10_000 });
+
+ // Wait for the table to render with styled rows
+ // The table should have at least one row with inline background-color style
+ await expect(async () => {
+ const styledRows = page.locator("[data-testid='widget-card'] tr[style*='background']");
+ await expect(styledRows.first()).toBeVisible({ timeout: 5_000 });
+ }).toPass({ timeout: 30_000 });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Styling rules — bar chart
+// ---------------------------------------------------------------------------
+
+test.describe("Styling rules — bar chart", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.beforeEach(async ({ authPage, page }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Styling Bar ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+ });
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("should enable styling for bar chart", async ({ page }) => {
+ test.setTimeout(60_000);
+
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Bar Chart is default — select Neo4j connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option", { name: /Movies Graph/ }).click();
+
+ // Query and run
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.title AS label, m.released AS value LIMIT 5",
+ );
+ await expect(dialog.getByTitle("Run query (Ctrl+Enter / \u2318+Enter)")).toBeEnabled({
+ timeout: 10_000,
+ });
+ await dialog.getByTitle("Run query (Ctrl+Enter / \u2318+Enter)").click();
+ await expect(getPreview(dialog)).toBeVisible({ timeout: 15_000 });
+
+ // Navigate to Advanced tab
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+
+ // Enable styling
+ await dialog.getByLabel("Enable rule-based styling").click();
+ await expect(dialog.getByText("No styling rules configured.")).toBeVisible();
+
+ // Open Styling Rules editor
+ await dialog.getByRole("button", { name: "Manage Styling Rules" }).click();
+
+ // Add a rule
+ await page.getByRole("button", { name: "Add Rule" }).click();
+ await expect(page.getByText("Rule 1")).toBeVisible();
+
+ // Per-rule "Column" selector should NOT be visible for bar charts (only tables)
+ await expect(page.locator("text=Column").first()).not.toBeVisible();
+
+ // Click Done
+ await page.getByRole("button", { name: "Done" }).click();
+
+ // Verify rule count
+ await dialog.getByRole("tab", { name: "Advanced" }).click();
+ await expect(dialog.getByText("1 styling rule(s) configured.")).toBeVisible();
+
+ // Save without errors
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog).not.toBeVisible({ timeout: 5_000 });
+ });
+});
diff --git a/app/e2e/theme.spec.ts b/app/e2e/theme.spec.ts
new file mode 100644
index 000000000..54764c17b
--- /dev/null
+++ b/app/e2e/theme.spec.ts
@@ -0,0 +1,85 @@
+import { test, expect, ALICE } from "./fixtures";
+
+test.describe("Theme toggle", () => {
+ test.beforeEach(async ({ authPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ });
+
+ test("switch to dark mode adds .dark class to html", async ({ page }) => {
+ await page.getByRole("button", { name: "Theme" }).click();
+ await page.getByRole("menuitemradio", { name: "Dark" }).click();
+
+ await expect(page.locator("html")).toHaveClass(/dark/);
+ });
+
+ test("switch to light mode removes .dark class", async ({ page }) => {
+ // Set dark first, then switch to light
+ await page.getByRole("button", { name: "Theme" }).click();
+ await page.getByRole("menuitemradio", { name: "Dark" }).click();
+ await expect(page.locator("html")).toHaveClass(/dark/);
+
+ await page.getByRole("button", { name: "Theme" }).click();
+ await page.getByRole("menuitemradio", { name: "Light" }).click();
+ await expect(page.locator("html")).not.toHaveClass(/dark/);
+ });
+
+ test("system theme follows prefers-color-scheme", async ({ page }) => {
+ // Emulate dark color scheme
+ await page.emulateMedia({ colorScheme: "dark" });
+
+ await page.getByRole("button", { name: "Theme" }).click();
+ await page.getByRole("menuitemradio", { name: "System" }).click();
+ await expect(page.locator("html")).toHaveClass(/dark/);
+
+ // Switch to light scheme
+ await page.emulateMedia({ colorScheme: "light" });
+ await expect(page.locator("html")).not.toHaveClass(/dark/, {
+ timeout: 5_000,
+ });
+ });
+
+ test("theme preference persists after reload", async ({ page }) => {
+ await page.getByRole("button", { name: "Theme" }).click();
+ await page.getByRole("menuitemradio", { name: "Dark" }).click();
+ await expect(page.locator("html")).toHaveClass(/dark/);
+
+ await page.reload({ waitUntil: "networkidle" });
+
+ // Dark class should still be applied (persisted via localStorage)
+ await expect(page.locator("html")).toHaveClass(/dark/);
+ // Sidebar should be visible (proves the page loaded correctly)
+ await expect(page.getByRole("button", { name: "Dashboards" })).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Clean up — restore to system default
+ await page.getByRole("button", { name: "Theme" }).click();
+ await page.getByRole("menuitemradio", { name: "System" }).click();
+ });
+
+ test("theme applies on dashboard view page", async ({ page }) => {
+ // Navigate to a dashboard
+ await page.getByText("Movie Analytics", { exact: true }).click();
+ await page.waitForURL(/\/[\w-]+$/, { timeout: 10_000 });
+
+ await page.getByRole("button", { name: "Theme" }).click();
+ await page.getByRole("menuitemradio", { name: "Dark" }).click();
+ await expect(page.locator("html")).toHaveClass(/dark/);
+
+ // Verify background color changed (dark theme uses a dark background)
+ const bgColor = await page
+ .locator("body")
+ .evaluate((el) =>
+ getComputedStyle(el).getPropertyValue("background-color"),
+ );
+ // Dark mode background should have low RGB values
+ const match = bgColor.match(/\d+/g);
+ expect(match).toBeTruthy();
+ const [r, g, b] = match!.map(Number);
+ expect(r + g + b).toBeLessThan(200);
+
+ // Clean up
+ await page.getByRole("button", { name: "Theme" }).click();
+ await page.getByRole("menuitemradio", { name: "System" }).click();
+ });
+});
diff --git a/app/e2e/transforms.spec.ts b/app/e2e/transforms.spec.ts
new file mode 100644
index 000000000..73a6232cd
--- /dev/null
+++ b/app/e2e/transforms.spec.ts
@@ -0,0 +1,180 @@
+import {
+ test,
+ expect,
+ ALICE,
+ createTestDashboard,
+ typeInEditor,
+ getPreview,
+} from "./fixtures";
+
+// ---------------------------------------------------------------------------
+// Helper: open Add Widget dialog, select connection, type query, run it,
+// and wait for preview. Shared across all transform tests.
+// ---------------------------------------------------------------------------
+async function setupWidgetWithQuery(page: import("@playwright/test").Page) {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select Neo4j connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Wait for editor to stabilize after connection selection triggers schema fetch
+ await page.waitForTimeout(1_000);
+
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.released AS year, count(*) AS count ORDER BY year",
+ );
+ // Wait for Run button and click it
+ const runBtn = dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)");
+ await expect(runBtn).toBeEnabled({ timeout: 15_000 });
+ await runBtn.click();
+
+ // Wait for preview to render — the widget-preview testid appears only after data arrives
+ await expect(dialog.getByTestId("widget-preview")).toBeVisible({
+ timeout: 20_000,
+ });
+
+ return dialog;
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+test.describe("Data Transforms", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.beforeEach(async ({ authPage, page }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `transforms-${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+ });
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("Transform tab shows empty state and Add button", async ({ page }) => {
+ test.setTimeout(90_000);
+ const dialog = await setupWidgetWithQuery(page);
+
+ // Switch to Transform tab
+ await dialog.getByRole("tab", { name: "Transform" }).click();
+
+ // Should show empty state text + Add button
+ await expect(dialog.getByText(/no transforms configured/i)).toBeVisible();
+ await expect(
+ dialog.getByRole("button", { name: "Add", exact: true }),
+ ).toBeVisible();
+ });
+
+ test("Add a filter transform — card appears with fields", async ({
+ page,
+ }) => {
+ test.setTimeout(90_000);
+ const dialog = await setupWidgetWithQuery(page);
+
+ await dialog.getByRole("tab", { name: "Transform" }).click();
+ await dialog.getByRole("button", { name: "Add", exact: true }).click();
+
+ // Filter card should appear with "1. Filter" badge
+ await expect(dialog.getByText("1. Filter")).toBeVisible();
+
+ // Remove button should be visible
+ await expect(
+ dialog.getByRole("button", { name: "Remove transform" }),
+ ).toBeVisible();
+ });
+
+ test("Add two transforms and remove first — renumbers correctly", async ({
+ page,
+ }) => {
+ test.setTimeout(90_000);
+ const dialog = await setupWidgetWithQuery(page);
+
+ await dialog.getByRole("tab", { name: "Transform" }).click();
+
+ // Add two transforms
+ await dialog.getByRole("button", { name: "Add", exact: true }).click();
+ await expect(dialog.getByText("1. Filter")).toBeVisible();
+ await dialog.getByRole("button", { name: "Add", exact: true }).click();
+ await expect(dialog.getByText("2. Filter")).toBeVisible();
+
+ // Remove the first one
+ const removeButtons = dialog.getByRole("button", {
+ name: "Remove transform",
+ });
+ await removeButtons.first().click();
+
+ // Should renumber: only "1. Filter" remains
+ await expect(dialog.getByText("1. Filter")).toBeVisible();
+ await expect(dialog.getByText("2. Filter")).not.toBeVisible();
+ });
+
+ test("Save widget with transforms — transforms persist on reopen", async ({
+ page,
+ }) => {
+ test.setTimeout(120_000);
+ const dialog = await setupWidgetWithQuery(page);
+
+ // Add a filter transform
+ await dialog.getByRole("tab", { name: "Transform" }).click();
+ await dialog.getByRole("button", { name: "Add", exact: true }).click();
+ await expect(dialog.getByText("1. Filter")).toBeVisible();
+
+ // Save the widget
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog).not.toBeVisible({ timeout: 10_000 });
+
+ // Save the dashboard
+ await page.getByRole("button", { name: /save/i }).click();
+ await page.waitForTimeout(1_000);
+
+ // Reopen the widget editor
+ const widgetCard = page.locator("[data-testid='widget-card']").first();
+ await widgetCard.hover();
+ await widgetCard.getByRole("button", { name: "Widget actions" }).click();
+ await page.getByRole("menuitem", { name: /edit/i }).click();
+
+ // Verify edit dialog opens
+ const editDialog = page.getByRole("dialog", { name: "Edit Widget" });
+ await expect(editDialog).toBeVisible({ timeout: 10_000 });
+
+ // Switch to Transform tab — saved transform should be there
+ await editDialog.getByRole("tab", { name: "Transform" }).click();
+ await expect(editDialog.getByText("1. Filter")).toBeVisible({
+ timeout: 5_000,
+ });
+ });
+
+ test("Enable transforms toggle controls preview", async ({ page }) => {
+ test.setTimeout(90_000);
+ const dialog = await setupWidgetWithQuery(page);
+
+ await dialog.getByRole("tab", { name: "Transform" }).click();
+
+ // Add a limit transform (reduces data)
+ await dialog.getByRole("button", { name: "Add", exact: true }).click();
+ await expect(dialog.getByText("1. Filter")).toBeVisible();
+
+ // Toggle should be checked by default
+ const toggle = dialog.locator("#transforms-enabled");
+ await expect(toggle).toBeChecked();
+
+ // Uncheck — transforms should be disabled
+ await toggle.uncheck();
+ await expect(toggle).not.toBeChecked();
+
+ // Re-check — transforms re-enabled
+ await toggle.check();
+ await expect(toggle).toBeChecked();
+ });
+});
diff --git a/app/e2e/users.spec.ts b/app/e2e/users.spec.ts
new file mode 100644
index 000000000..3377c269b
--- /dev/null
+++ b/app/e2e/users.spec.ts
@@ -0,0 +1,249 @@
+import { test, expect, ALICE } from "./fixtures";
+
+test.describe("User management", () => {
+ test.beforeEach(async ({ authPage, sidebarPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ await sidebarPage.navigateTo("Users");
+ });
+
+ test("should show users page with current users", async ({ page }) => {
+ await expect(
+ page.getByRole("heading", { level: 1, name: "Users" }),
+ ).toBeVisible();
+ // Should show at least the seeded users
+ await expect(page.getByText("alice@example.com")).toBeVisible();
+ });
+
+ test("should create a new user", async ({ page }) => {
+ // Wait for user data to load (avoids duplicate "Create User" buttons from EmptyState)
+ await expect(page.getByText("alice@example.com")).toBeVisible({
+ timeout: 10000,
+ });
+ await page.getByRole("button", { name: "Create User" }).first().click();
+ const dialog = page.getByRole("dialog");
+ const timestamp = Date.now();
+ await dialog.locator("#user-name").fill("Test User");
+ await dialog.locator("#user-email").fill(`test-${timestamp}@example.com`);
+ await dialog.locator("#user-password").fill("password123");
+ await dialog.getByRole("button", { name: "Create" }).click();
+
+ await expect(page.getByText(`test-${timestamp}@example.com`)).toBeVisible();
+ });
+
+ test("should change user role via dropdown", async ({ page }) => {
+ // Wait for user data to load
+ await expect(page.getByText("alice@example.com")).toBeVisible({
+ timeout: 10000,
+ });
+ // Create a fresh user as "creator"
+ await page.getByRole("button", { name: "Create User" }).first().click();
+ const dialog = page.getByRole("dialog");
+ const timestamp = Date.now();
+ const email = `test-role-${timestamp}@example.com`;
+ await dialog.locator("#user-name").fill("Role Test User");
+ await dialog.locator("#user-email").fill(email);
+ await dialog.locator("#user-password").fill("password123");
+ // Creator is the default role — no change needed
+ await dialog.getByRole("button", { name: "Create" }).click();
+ await expect(page.getByText(email)).toBeVisible({ timeout: 10000 });
+
+ // Find the user's row and click the role Select dropdown
+ const row = page.getByRole("row").filter({ hasText: email });
+ await row.getByRole("combobox").click();
+ // Select "Reader"
+ await page.getByRole("option", { name: "Reader" }).click();
+
+ // Assert toast "Role updated" appears (use exact match to avoid strict-mode
+ // violation from the aria-live status announcement that also contains "Role updated")
+ await expect(page.getByText("Role updated", { exact: true })).toBeVisible({
+ timeout: 5000,
+ });
+
+ // Verify the role changed — Select now shows "Reader"
+ await expect(row.getByRole("combobox")).toHaveText("Reader");
+ });
+
+ test("should delete a user with confirmation", async ({ page }) => {
+ // Wait for user data to load
+ await expect(page.getByText("alice@example.com")).toBeVisible({
+ timeout: 10000,
+ });
+ // Create a user to delete
+ await page.getByRole("button", { name: "Create User" }).first().click();
+ const dialog = page.getByRole("dialog");
+ const timestamp = Date.now();
+ const email = `delete-${timestamp}@example.com`;
+ await dialog.locator("#user-name").fill("To Delete");
+ await dialog.locator("#user-email").fill(email);
+ await dialog.locator("#user-password").fill("password123");
+ await dialog.getByRole("button", { name: "Create" }).click();
+ await expect(page.getByText(email)).toBeVisible();
+
+ // Find the row and open actions dropdown, then click Delete
+ const row = page.getByRole("row").filter({ hasText: email });
+ await row.getByRole("button", { name: "User actions" }).click();
+ await page.getByRole("menuitem", { name: "Delete" }).click();
+ // Confirm deletion in the confirm dialog
+ await page.getByRole("button", { name: "Delete" }).last().click();
+ await expect(page.getByText(email)).not.toBeVisible();
+ });
+});
+
+test.describe("Force password change", () => {
+ test.beforeEach(async ({ authPage, sidebarPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ await sidebarPage.navigateTo("Users");
+ });
+
+ test("should create user with require password change checkbox", async ({
+ page,
+ }) => {
+ await expect(page.getByText("alice@example.com")).toBeVisible({
+ timeout: 10_000,
+ });
+ await page.getByRole("button", { name: "Create User" }).first().click();
+ const dialog = page.getByRole("dialog");
+ const timestamp = Date.now();
+ const email = `force-pw-${timestamp}@example.com`;
+ await dialog.locator("#user-name").fill("Force PW User");
+ await dialog.locator("#user-email").fill(email);
+ await dialog.locator("#user-password").fill("password123");
+ // Check the require password change checkbox
+ await dialog.locator("#user-force-password-change").click();
+ await dialog.getByRole("button", { name: "Create" }).click();
+
+ await expect(page.getByText(email)).toBeVisible();
+ });
+
+ test("admin can trigger require password change from dropdown", async ({
+ page,
+ }) => {
+ // Create a user first
+ await expect(page.getByText("alice@example.com")).toBeVisible({
+ timeout: 10_000,
+ });
+ await page.getByRole("button", { name: "Create User" }).first().click();
+ const dialog = page.getByRole("dialog");
+ const timestamp = Date.now();
+ const email = `reset-pw-${timestamp}@example.com`;
+ await dialog.locator("#user-name").fill("Reset PW User");
+ await dialog.locator("#user-email").fill(email);
+ await dialog.locator("#user-password").fill("password123");
+ await dialog.getByRole("button", { name: "Create" }).click();
+ await expect(page.getByText(email)).toBeVisible();
+
+ // Open actions dropdown and click Require Password Change
+ const row = page.getByRole("row").filter({ hasText: email });
+ await row.getByRole("button", { name: "User actions" }).click();
+ await page
+ .getByRole("menuitem", { name: "Require Password Change" })
+ .click();
+
+ // Should show temp password dialog with copy button
+ const tempDialog = page.getByRole("dialog", { name: "Temporary Password" });
+ await expect(tempDialog).toBeVisible({ timeout: 10_000 });
+ await expect(
+ tempDialog.getByText("temporary password has been generated"),
+ ).toBeVisible();
+ await expect(tempDialog.locator("code")).toBeVisible();
+ await expect(
+ tempDialog.getByRole("button", { name: /copy/i }),
+ ).toBeVisible();
+ await tempDialog.getByRole("button", { name: "Done" }).click();
+ });
+});
+
+test.describe("can_write toggle", () => {
+ /** Helper: create a fresh creator user and return their email. */
+ async function createCreator(
+ page: import("@playwright/test").Page,
+ label: string,
+ ) {
+ const email = `${label}-${Date.now()}@example.com`;
+ await expect(page.getByText("alice@example.com")).toBeVisible({
+ timeout: 10_000,
+ });
+ await page.getByRole("button", { name: "Create User" }).first().click();
+ const dialog = page.getByRole("dialog");
+ await dialog.locator("#user-name").fill("Test Creator");
+ await dialog.locator("#user-email").fill(email);
+ await dialog.locator("#user-password").fill("password123");
+ // Creator is the default role — no change needed
+ await dialog.getByRole("button", { name: "Create" }).click();
+ await expect(page.getByText(email)).toBeVisible({ timeout: 10_000 });
+ return email;
+ }
+
+ test.beforeEach(async ({ authPage, sidebarPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ await sidebarPage.navigateTo("Users");
+ });
+
+ test("Write column shows Yes badge for creators by default", async ({
+ page,
+ }) => {
+ const email = await createCreator(page, "badge-test");
+ const row = page.getByRole("row").filter({ hasText: email });
+ // Admin sees a Switch in the Write column; checked = canWrite enabled
+ await expect(row.getByRole("switch")).toBeChecked();
+ });
+
+ test("admin can toggle can_write off for a creator", async ({ page }) => {
+ const email = await createCreator(page, "toggle-off");
+ const row = page.getByRole("row").filter({ hasText: email });
+
+ // Default: write enabled (switch checked)
+ await expect(row.getByRole("switch")).toBeChecked();
+
+ // Toggle off
+ await row.getByRole("switch").click();
+ await expect(row.getByRole("switch")).not.toBeChecked({ timeout: 5_000 });
+ });
+
+ test("admin can toggle can_write back on after disabling", async ({
+ page,
+ }) => {
+ const email = await createCreator(page, "toggle-on");
+ const row = page.getByRole("row").filter({ hasText: email });
+
+ // Disable first
+ await row.getByRole("switch").click();
+ await expect(row.getByRole("switch")).not.toBeChecked({ timeout: 5_000 });
+
+ // Re-enable
+ await row.getByRole("switch").click();
+ await expect(row.getByRole("switch")).toBeChecked({ timeout: 5_000 });
+ });
+
+ test("Write switch is disabled for the admin's own row", async ({ page }) => {
+ await expect(page.getByText("alice@example.com")).toBeVisible({
+ timeout: 10_000,
+ });
+ const aliceRow = page
+ .getByRole("row")
+ .filter({ hasText: "alice@example.com" });
+ // Own row: switch is wrapped in a disabled span (cursor-not-allowed)
+ await expect(aliceRow.getByRole("switch")).toBeDisabled();
+ });
+
+ test("Write switch is disabled for reader-role users", async ({ page }) => {
+ const email = `reader-nowrite-${Date.now()}@example.com`;
+ await expect(page.getByText("alice@example.com")).toBeVisible({
+ timeout: 10_000,
+ });
+ await page.getByRole("button", { name: "Create User" }).first().click();
+ const dialog = page.getByRole("dialog");
+ await dialog.locator("#user-name").fill("Test Reader");
+ await dialog.locator("#user-email").fill(email);
+ await dialog.locator("#user-password").fill("password123");
+ await dialog.locator("#user-role").click();
+ await page.getByRole("option", { name: "Reader" }).click();
+ await dialog.getByRole("button", { name: "Create" }).click();
+ await expect(page.getByText(email)).toBeVisible({ timeout: 10_000 });
+
+ const row = page.getByRole("row").filter({ hasText: email });
+ // Reader always shows No and the switch is disabled
+ await expect(row.getByText("No")).toBeVisible();
+ await expect(row.getByRole("switch")).toBeDisabled();
+ });
+});
diff --git a/app/e2e/widget-lab.spec.ts b/app/e2e/widget-lab.spec.ts
new file mode 100644
index 000000000..7bfc89991
--- /dev/null
+++ b/app/e2e/widget-lab.spec.ts
@@ -0,0 +1,862 @@
+import {
+ test,
+ expect,
+ ALICE,
+ CAROL,
+ createTestDashboard,
+ typeInEditor,
+ getPreview,
+} from "./fixtures";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Create a widget via the Add Widget dialog and return without saving the dashboard. */
+async function addBarWidgetToDashboard(page: import("@playwright/test").Page) {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select Neo4j connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Type a query using the reliable typeInEditor helper
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.title AS label, m.released AS value LIMIT 5",
+ );
+
+ // Add the widget
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog).not.toBeVisible();
+}
+
+// ---------------------------------------------------------------------------
+// Suite
+// ---------------------------------------------------------------------------
+
+test.describe("Widget Lab", () => {
+ test.beforeEach(async ({ authPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ });
+
+ // ── Sidebar navigation ──────────────────────────────────────────────
+
+ test("sidebar has Widget Lab item that navigates to /widget-lab", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await page.getByRole("button", { name: "Widget Lab" }).click();
+ await expect(page).toHaveURL("/widget-lab");
+ await expect(
+ page.getByRole("heading", { name: "Widget Lab" }),
+ ).toBeVisible();
+ });
+
+ test("Widget Lab page shows empty state when no templates exist", async ({
+ page,
+ }) => {
+ await page.goto("/widget-lab");
+ // Either the empty-state copy or template cards should render
+ await expect(
+ page
+ .getByText("No templates yet")
+ .or(page.getByText("No templates match your filters"))
+ .or(page.locator(".grid > div").first()),
+ ).toBeVisible({ timeout: 10_000 });
+ });
+
+ // ── Save to Widget Lab flow ─────────────────────────────────────────
+
+ test.describe("Save / browse / delete template flow", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+ let templateId: string | undefined;
+
+ test.beforeEach(async ({ page }) => {
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Widget Lab Test ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+
+ // Add a bar widget
+ await addBarWidgetToDashboard(page);
+ });
+
+ test.afterEach(async ({ page }) => {
+ // Clean up any template created during the test
+ if (templateId) {
+ await page.request.delete(`/api/widget-templates/${templateId}`);
+ templateId = undefined;
+ }
+ await dashboardCleanup?.();
+ });
+
+ test("can save a widget as a template and see it in Widget Lab", async ({
+ page,
+ }) => {
+ // Open widget actions menu → "Save to Widget Lab"
+ const widgetCard = page.locator("[data-testid='widget-card']").first();
+ await widgetCard.hover();
+ await widgetCard.getByRole("button", { name: "Widget actions" }).click();
+ await page.getByRole("menuitem", { name: "Save to Widget Lab" }).click();
+
+ // Save Template dialog should appear
+ const saveDialog = page.getByRole("dialog", {
+ name: "Save to Widget Lab",
+ });
+ await expect(saveDialog).toBeVisible();
+
+ // Fill in template name
+ const templateName = `E2E Template ${Date.now()}`;
+ await saveDialog.getByLabel("Name").fill(templateName);
+ await saveDialog.getByLabel(/description/i).fill("Created by E2E test");
+
+ // Save
+ await saveDialog.getByRole("button", { name: "Save Template" }).click();
+ await expect(saveDialog).not.toBeVisible();
+
+ // Navigate to Widget Lab and verify the template appears
+ await page.goto("/widget-lab");
+ await expect(page.getByText(templateName)).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Capture template id for cleanup
+ const res = await page.request.get("/api/widget-templates");
+ const templates = (await res.json()).data;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const saved = templates.find((t: any) => t.name === templateName);
+ templateId = saved?.id;
+ });
+
+ test("can delete a template from Widget Lab", async ({ page }) => {
+ // First save a template via the API so we don't depend on the UI flow
+ const templateName = `E2E Delete ${Date.now()}`;
+ const createRes = await page.request.post("/api/widget-templates", {
+ data: {
+ name: templateName,
+ chartType: "bar",
+ connectorType: "neo4j",
+ query: "MATCH (m:Movie) RETURN m.title LIMIT 5",
+ },
+ });
+ expect(createRes.ok()).toBeTruthy();
+ const { id } = (await createRes.json()).data;
+ templateId = id;
+
+ // Go to Widget Lab
+ await page.goto("/widget-lab");
+ await expect(page.getByText(templateName)).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Use data-testid for reliable card selection instead of .grid > div
+ const card = page
+ .getByTestId("template-card")
+ .filter({ hasText: templateName });
+ await card.getByRole("button", { name: "Delete template" }).click();
+
+ // Confirm the deletion
+ const confirmDialog = page.getByRole("alertdialog", {
+ name: "Delete Template",
+ });
+ await expect(confirmDialog).toBeVisible();
+ await confirmDialog.getByRole("button", { name: "Delete" }).click();
+ await expect(confirmDialog).not.toBeVisible();
+
+ // Template should no longer appear
+ await expect(page.getByText(templateName)).not.toBeVisible({
+ timeout: 5_000,
+ });
+ templateId = undefined; // Already deleted
+ });
+ });
+
+ // ── From Template in Add Widget dialog ──────────────────────────────
+
+ test.describe("From Template in Add Widget modal", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+ let templateId: string | undefined;
+ let templateName: string;
+
+ test.beforeEach(async ({ page }) => {
+ templateName = `E2E Tmpl ${Date.now()}`;
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Widget Lab From Template ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+
+ // Create a template via API
+ const res = await page.request.post("/api/widget-templates", {
+ data: {
+ name: templateName,
+ description: "Picked in E2E test",
+ chartType: "table",
+ connectorType: "neo4j",
+ query: "MATCH (m:Movie) RETURN m.title LIMIT 5",
+ },
+ });
+ expect(res.ok()).toBeTruthy();
+ const { id: tId } = (await res.json()).data;
+ templateId = tId;
+
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+ });
+
+ test.afterEach(async ({ page }) => {
+ if (templateId) {
+ await page.request.delete(`/api/widget-templates/${templateId}`);
+ templateId = undefined;
+ }
+ await dashboardCleanup?.();
+ });
+
+ test("can open From Template and apply a template to the widget form", async ({
+ page,
+ }) => {
+ // Open Add Widget dialog
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+ await expect(dialog).toBeVisible();
+
+ // Click "From Template"
+ await dialog.getByRole("button", { name: "From Template" }).click();
+
+ // Dialog title changes to "Browse Templates"
+ const browseDialog = page.getByRole("dialog", {
+ name: "Browse Templates",
+ });
+ await expect(
+ browseDialog.getByRole("heading", { name: "Browse Templates" }),
+ ).toBeVisible();
+
+ // The template we created should be listed
+ await expect(
+ browseDialog.locator("button").filter({ hasText: templateName }),
+ ).toBeVisible({ timeout: 10_000 });
+
+ // Click to apply
+ await browseDialog
+ .locator("button")
+ .filter({ hasText: templateName })
+ .click();
+
+ // Should return to main dialog step (title changes back)
+ const mainDialog = page.getByRole("dialog", { name: "Add Widget" });
+ await expect(
+ mainDialog.getByRole("heading", { name: "Add Widget" }),
+ ).toBeVisible();
+
+ // Query should be pre-filled from the template
+ await expect(
+ mainDialog.locator("[data-testid='codemirror-container']"),
+ ).toBeVisible();
+ });
+
+ test("From Template picker shows code preview for each template", async ({
+ page,
+ }) => {
+ // Open Add Widget dialog
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+ await expect(dialog).toBeVisible();
+
+ // Click "From Template"
+ await dialog.getByRole("button", { name: "From Template" }).click();
+ const browseDialog = page.getByRole("dialog", {
+ name: "Browse Templates",
+ });
+ await expect(
+ browseDialog.getByRole("heading", { name: "Browse Templates" }),
+ ).toBeVisible();
+
+ // Wait for templates to load — use button filter to avoid matching alt text
+ const card = browseDialog
+ .locator("button")
+ .filter({ hasText: templateName });
+ await expect(card).toBeVisible({ timeout: 10_000 });
+
+ // The card should contain a code preview with the query text
+ await expect(card.locator("[data-testid='code-preview']")).toBeVisible();
+ });
+ });
+
+ // ── Create / Edit templates directly in Widget Lab ──────────────────
+
+ test.describe("Widget Lab editor — create and edit templates", () => {
+ let templateId: string | undefined;
+
+ test.afterEach(async ({ page }) => {
+ if (templateId) {
+ await page.request.delete(`/api/widget-templates/${templateId}`);
+ templateId = undefined;
+ }
+ });
+
+ test("can create a new template directly from Widget Lab", async ({
+ page,
+ }) => {
+ test.setTimeout(60_000);
+ await page.goto("/widget-lab");
+ await expect(
+ page.getByRole("heading", { name: "Widget Lab" }),
+ ).toBeVisible();
+
+ // Click "New Template" button
+ await page.getByRole("button", { name: "New Template" }).click();
+ const dialog = page.getByRole("dialog", { name: "Create Template" });
+ await expect(dialog).toBeVisible();
+
+ // Fill in template metadata
+ const templateName = `E2E Create ${Date.now()}`;
+ await dialog.locator("#lab-template-name").fill(templateName);
+ await dialog
+ .locator("#lab-template-desc")
+ .fill("Created directly in Widget Lab");
+ await dialog.locator("#lab-template-tags").fill("e2e, test");
+
+ // Select a connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Type a query
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.title AS label, m.released AS value LIMIT 5",
+ );
+
+ // Run the query to populate the preview (use first() — there may be
+ // duplicate Run buttons when CM6 re-renders during mount)
+ await dialog.getByRole("button", { name: "Run" }).first().click();
+ const preview = getPreview(dialog);
+ await expect(
+ preview.locator("canvas").or(preview.locator("table")),
+ ).toBeVisible({ timeout: 15_000 });
+
+ // Create the template
+ await dialog.getByRole("button", { name: "Create Template" }).click();
+ await expect(dialog).not.toBeVisible({ timeout: 10_000 });
+
+ // Verify it appears in the Widget Lab list
+ await expect(page.getByText(templateName)).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Capture template id for cleanup
+ const res = await page.request.get("/api/widget-templates");
+ const templates = (await res.json()).data;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const saved = templates.find((t: any) => t.name === templateName);
+ templateId = saved?.id;
+ expect(templateId).toBeDefined();
+ });
+
+ test("can edit an existing template in Widget Lab", async ({ page }) => {
+ test.setTimeout(60_000);
+
+ // Create a template via API first
+ const origName = `E2E Edit Orig ${Date.now()}`;
+ const createRes = await page.request.post("/api/widget-templates", {
+ data: {
+ name: origName,
+ chartType: "bar",
+ connectorType: "neo4j",
+ query:
+ "MATCH (m:Movie) RETURN m.title AS label, m.released AS value LIMIT 5",
+ settings: { title: "Bar Chart" },
+ },
+ });
+ expect(createRes.ok()).toBeTruthy();
+ const { id } = (await createRes.json()).data;
+ templateId = id;
+
+ // Go to Widget Lab and click edit on the template card
+ await page.goto("/widget-lab");
+ await expect(page.getByText(origName)).toBeVisible({ timeout: 10_000 });
+
+ const card = page
+ .locator("[data-testid='template-card']")
+ .filter({ hasText: origName });
+ await card.getByRole("button", { name: "Edit template" }).click();
+
+ // Edit Template dialog should open
+ const dialog = page.getByRole("dialog", { name: "Edit Template" });
+ await expect(dialog).toBeVisible();
+
+ // Verify metadata is pre-filled
+ await expect(dialog.locator("#lab-template-name")).toHaveValue(origName);
+
+ // Change the name
+ const newName = `E2E Edit Updated ${Date.now()}`;
+ await dialog.locator("#lab-template-name").fill(newName);
+
+ // Save
+ await dialog.getByRole("button", { name: "Save Template" }).click();
+ await expect(dialog).not.toBeVisible({ timeout: 10_000 });
+
+ // Verify updated name appears
+ await expect(page.getByText(newName)).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByText(origName)).not.toBeVisible();
+ });
+
+ test("template cards show code preview with query text", async ({
+ page,
+ }) => {
+ test.setTimeout(60_000);
+
+ // Create a template via API
+ const templateName = `E2E Preview ${Date.now()}`;
+ const queryText = "MATCH (n) RETURN n LIMIT 10";
+
+ const createRes = await page.request.post("/api/widget-templates", {
+ data: {
+ name: templateName,
+ chartType: "bar",
+ connectorType: "neo4j",
+ query: queryText,
+ },
+ });
+ expect(createRes.ok()).toBeTruthy();
+ const { id } = (await createRes.json()).data;
+ templateId = id;
+
+ // Navigate to Widget Lab
+ await page.goto("/widget-lab");
+ await expect(page.getByText(templateName)).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // The template card should show a code preview containing the query
+ const card = page
+ .locator("[data-testid='template-card']")
+ .filter({ hasText: templateName });
+ const codePreview = card.locator("[data-testid='code-preview']");
+ await expect(codePreview).toBeVisible();
+ await expect(codePreview).toContainText(queryText);
+ });
+
+ test("Use in Dashboard opens picker dialog and navigates to dashboard editor", async ({
+ page,
+ }) => {
+ test.setTimeout(90_000);
+
+ // Create a template via API
+ const templateName = `E2E UseInDash ${Date.now()}`;
+ const queryText =
+ "MATCH (m:Movie) RETURN m.title AS label, m.released AS value LIMIT 5";
+ const createRes = await page.request.post("/api/widget-templates", {
+ data: {
+ name: templateName,
+ chartType: "bar",
+ connectorType: "neo4j",
+ query: queryText,
+ },
+ });
+ expect(createRes.ok()).toBeTruthy();
+ const { id: tId } = (await createRes.json()).data;
+ templateId = tId;
+
+ // Create a dashboard to use as target
+ const { id: dashId, cleanup: dashCleanup } = await createTestDashboard(
+ page.request,
+ `UseInDash Target ${Date.now()}`,
+ );
+
+ try {
+ // Go to Widget Lab
+ await page.goto("/widget-lab");
+ await expect(page.getByText(templateName)).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Click "Use in Dashboard" on the template card
+ const card = page
+ .locator("[data-testid='template-card']")
+ .filter({ hasText: templateName });
+ await card.getByRole("button", { name: "Use in Dashboard" }).click();
+
+ // Dashboard picker dialog should appear
+ const pickerDialog = page.getByRole("dialog", {
+ name: "Choose a Dashboard",
+ });
+ await expect(pickerDialog).toBeVisible({ timeout: 10_000 });
+
+ // Click the target dashboard
+ await pickerDialog
+ .locator("button")
+ .filter({ hasText: /UseInDash Target/ })
+ .click();
+
+ // Should navigate to the dashboard edit page with templateId param
+ await expect(page).toHaveURL(
+ new RegExp(`/${dashId}/edit\\?templateId=${tId}`),
+ );
+
+ // The Add Widget dialog should auto-open with the template applied
+ const addDialog = page.getByRole("dialog", { name: "Add Widget" });
+ await expect(addDialog).toBeVisible({ timeout: 15_000 });
+
+ // The query from the template should be pre-filled in the editor
+ await expect(
+ addDialog.locator("[data-testid='codemirror-container']"),
+ ).toBeVisible({ timeout: 10_000 });
+ } finally {
+ await dashCleanup();
+ }
+ });
+
+ test("editing a template does not affect widgets already on dashboards", async ({
+ page,
+ }) => {
+ test.setTimeout(90_000);
+
+ // 1. Create a template via API
+ const templateName = `E2E Isolation ${Date.now()}`;
+ const origQuery =
+ "MATCH (m:Movie) RETURN m.title AS label, m.released AS value LIMIT 5";
+ const createRes = await page.request.post("/api/widget-templates", {
+ data: {
+ name: templateName,
+ chartType: "bar",
+ connectorType: "neo4j",
+ query: origQuery,
+ settings: { title: "Original Title" },
+ },
+ });
+ expect(createRes.ok()).toBeTruthy();
+ const { id: tId } = (await createRes.json()).data;
+ templateId = tId;
+
+ // 2. Create a dashboard and add a widget from that template
+ const { id: dashId, cleanup } = await createTestDashboard(
+ page.request,
+ `Isolation Test ${Date.now()}`,
+ );
+
+ try {
+ await page.goto(`/${dashId}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+
+ // Add widget via "From Template"
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const addDialog = page.getByRole("dialog", { name: "Add Widget" });
+ await expect(addDialog).toBeVisible();
+
+ await addDialog.getByRole("button", { name: "From Template" }).click();
+ const browseDialog = page.getByRole("dialog", {
+ name: "Browse Templates",
+ });
+ await expect(browseDialog.getByText(templateName)).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Apply the template
+ await browseDialog
+ .locator("button")
+ .filter({ hasText: templateName })
+ .click();
+
+ // Back on main dialog — select connection and add the widget
+ const mainDialog = page.getByRole("dialog", { name: "Add Widget" });
+ await expect(mainDialog).toBeVisible();
+
+ // Select a connection
+ await mainDialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ await mainDialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(mainDialog).not.toBeVisible();
+
+ // Save dashboard
+ await page.getByRole("button", { name: "Save" }).click();
+ // eslint-disable-next-line playwright/no-wait-for-timeout
+ await page.waitForTimeout(1_000);
+
+ // 3. Edit the template in Widget Lab — change its name
+ await page.goto("/widget-lab");
+ await expect(page.getByText(templateName)).toBeVisible({
+ timeout: 10_000,
+ });
+
+ const card = page
+ .locator("[data-testid='template-card']")
+ .filter({ hasText: templateName });
+ await card.getByRole("button", { name: "Edit template" }).click();
+
+ const editDialog = page.getByRole("dialog", { name: "Edit Template" });
+ await expect(editDialog).toBeVisible();
+
+ const updatedName = `${templateName} UPDATED`;
+ await editDialog.locator("#lab-template-name").fill(updatedName);
+ await editDialog.getByRole("button", { name: "Save Template" }).click();
+ await expect(editDialog).not.toBeVisible({ timeout: 10_000 });
+
+ // 4. Go back to the dashboard — widget should still work with original data
+ await page.goto(`/${dashId}`);
+ await expect(
+ page.locator("[data-testid='widget-card']").first(),
+ ).toBeVisible({ timeout: 15_000 });
+
+ // Widget should render (canvas for bar chart) — proving the dashboard copy is independent
+ await expect(
+ page
+ .locator("[data-testid='widget-card'] canvas")
+ .or(
+ page
+ .locator("[data-testid='widget-card']")
+ .getByText("Original Title"),
+ ),
+ ).toBeVisible({ timeout: 15_000 });
+ } finally {
+ await cleanup();
+ }
+ });
+ });
+
+ // ── Save to Widget Lab from view mode ─────────────────────────────
+
+ test.describe("Save to Widget Lab from view mode", () => {
+ test("action is visible on widget menu in view mode", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+
+ // Navigate to Movie Analytics dashboard (view mode, not edit)
+ const res = await page.request.get("/api/dashboards");
+ const dashboards = (await res.json()).data;
+ const movieAnalytics = (
+ dashboards as { id: string; name: string }[]
+ ).find((d) => d.name === "Movie Analytics");
+ expect(movieAnalytics).toBeTruthy();
+ await page.goto(`/${movieAnalytics!.id}`);
+
+ // Open widget actions menu
+ const widgetCard = page.locator("[data-testid='widget-card']").first();
+ await expect(widgetCard).toBeVisible({ timeout: 15_000 });
+ await widgetCard.hover();
+ await widgetCard.getByRole("button", { name: "Widget actions" }).click();
+
+ await expect(
+ page.getByRole("menuitem", { name: "Save to Widget Lab" }),
+ ).toBeVisible();
+ });
+
+ test("can save a widget from view mode and see it in Widget Lab", async ({
+ authPage,
+ page,
+ }) => {
+ test.setTimeout(60_000);
+ await authPage.login(ALICE.email, ALICE.password);
+
+ const res = await page.request.get("/api/dashboards");
+ const dashboards = (await res.json()).data;
+ const movieAnalytics = (
+ dashboards as { id: string; name: string }[]
+ ).find((d) => d.name === "Movie Analytics");
+ expect(movieAnalytics).toBeTruthy();
+ await page.goto(`/${movieAnalytics!.id}`);
+
+ // Open widget actions → Save to Widget Lab
+ const widgetCard = page.locator("[data-testid='widget-card']").first();
+ await expect(widgetCard).toBeVisible({ timeout: 15_000 });
+ await widgetCard.hover();
+ await widgetCard.getByRole("button", { name: "Widget actions" }).click();
+ await page.getByRole("menuitem", { name: "Save to Widget Lab" }).click();
+
+ // Fill and submit
+ const saveDialog = page.getByRole("dialog", {
+ name: "Save to Widget Lab",
+ });
+ await expect(saveDialog).toBeVisible();
+
+ const templateName = `View Mode Template ${Date.now()}`;
+ await saveDialog.getByLabel("Name").fill(templateName);
+ await saveDialog.getByRole("button", { name: "Save Template" }).click();
+ await expect(saveDialog).not.toBeVisible();
+
+ // Verify in Widget Lab
+ await page.goto("/widget-lab");
+ await expect(page.getByText(templateName)).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Clean up
+ const templatesRes = await page.request.get("/api/widget-templates");
+ const templates = (await templatesRes.json()).data;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const saved = templates.find((t: any) => t.name === templateName);
+ if (saved) {
+ await page.request.delete(`/api/widget-templates/${saved.id}`);
+ }
+ });
+
+ test("reader role does not see Save to Widget Lab action", async ({
+ authPage,
+ page,
+ }) => {
+ test.setTimeout(60_000);
+ await authPage.login(CAROL.email, CAROL.password);
+
+ // Navigate to Movie Analytics (shared/public dashboard)
+ const res = await page.request.get("/api/dashboards");
+ const dashboards = (await res.json()).data;
+ const movieAnalytics = (
+ dashboards as { id: string; name: string }[]
+ ).find((d) => d.name === "Movie Analytics");
+ expect(movieAnalytics).toBeTruthy();
+ await page.goto(`/${movieAnalytics!.id}`);
+
+ const widgetCard = page.locator("[data-testid='widget-card']").first();
+ await expect(widgetCard).toBeVisible({ timeout: 15_000 });
+ await widgetCard.hover();
+ await widgetCard.getByRole("button", { name: "Widget actions" }).click();
+
+ // Export CSV should be visible, but Save to Widget Lab should NOT
+ await expect(
+ page.getByRole("menuitem", { name: "Export CSV" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("menuitem", { name: "Save to Widget Lab" }),
+ ).not.toBeVisible();
+ });
+ });
+
+ // ── 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 }) => {
+ // Create two templates via API for filter/search tests
+ const neo4jBar = await page.request.post("/api/widget-templates", {
+ data: {
+ name: "Neo4j Bar Template",
+ chartType: "bar",
+ connectorType: "neo4j",
+ query: "MATCH (m:Movie) RETURN m.title AS label, count(*) AS value",
+ },
+ });
+ const pgTable = await page.request.post("/api/widget-templates", {
+ data: {
+ name: "PostgreSQL Table Template",
+ chartType: "table",
+ connectorType: "postgresql",
+ query: "SELECT title FROM movies LIMIT 5",
+ },
+ });
+ const t1 = (await neo4jBar.json()).data;
+ const t2 = (await pgTable.json()).data;
+ templateIds = [t1.id, t2.id];
+ });
+
+ test.afterEach(async ({ page }) => {
+ for (const id of templateIds) {
+ await page.request.delete(`/api/widget-templates/${id}`);
+ }
+ templateIds = [];
+ });
+
+ test("can duplicate a template", async ({ page }) => {
+ await page.goto("/widget-lab");
+ const card = page
+ .locator("[data-testid='template-card']")
+ .filter({ hasText: "Neo4j Bar Template" })
+ .first();
+ await expect(card).toBeVisible({ timeout: 10_000 });
+
+ await card.getByLabel("Duplicate").click();
+
+ // Duplicate should appear with "(copy)" suffix
+ await expect(
+ page.getByText("Neo4j Bar Template (copy)", { exact: true }),
+ ).toBeVisible({ timeout: 10_000 });
+
+ // Clean up the duplicate
+ const res = await page.request.get("/api/widget-templates");
+ const templates = (await res.json()).data;
+ const copy = templates.find(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (t: any) => t.name === "Neo4j Bar Template (copy)",
+ );
+ if (copy) templateIds.push(copy.id);
+ });
+
+ test("can filter templates by chart type", async ({ page }) => {
+ await page.goto("/widget-lab");
+ await expect(
+ page.getByText("Neo4j Bar Template", { exact: true }),
+ ).toBeVisible({ timeout: 10_000 });
+ await expect(
+ page.getByText("PostgreSQL Table Template", { exact: true }),
+ ).toBeVisible();
+
+ // Filter to bar charts only — shadcn Select uses combobox role
+ await page.locator("button[role='combobox']").nth(0).click();
+ await page.getByRole("option", { name: "Bar Chart" }).click();
+
+ await expect(
+ page.getByText("Neo4j Bar Template", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("PostgreSQL Table Template", { exact: true }),
+ ).not.toBeVisible();
+ });
+
+ test("can filter templates by connector type", async ({ page }) => {
+ await page.goto("/widget-lab");
+ await expect(
+ page.getByText("Neo4j Bar Template", { exact: true }),
+ ).toBeVisible({ timeout: 10_000 });
+
+ // Filter to PostgreSQL only — connector select is the second combobox
+ await page.locator("button[role='combobox']").nth(1).click();
+ await page.getByRole("option", { name: /PostgreSQL/i }).click();
+
+ await expect(
+ page.getByText("PostgreSQL Table Template", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("Neo4j Bar Template", { exact: true }),
+ ).not.toBeVisible();
+ });
+
+ test("can search templates by name", async ({ page }) => {
+ await page.goto("/widget-lab");
+ await expect(
+ page.getByText("Neo4j Bar Template", { exact: true }),
+ ).toBeVisible({ timeout: 10_000 });
+
+ // Search for "PostgreSQL"
+ await page.getByPlaceholder("Search templates...").fill("PostgreSQL");
+
+ await expect(
+ page.getByText("PostgreSQL Table Template", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("Neo4j Bar Template", { exact: true }),
+ ).not.toBeVisible();
+
+ // Clear search
+ await page.getByPlaceholder("Search templates...").clear();
+ await expect(
+ page.getByText("Neo4j Bar Template", { exact: true }),
+ ).toBeVisible();
+ });
+ });
+});
diff --git a/app/e2e/widget-states.spec.ts b/app/e2e/widget-states.spec.ts
new file mode 100644
index 000000000..f3dc5e3c8
--- /dev/null
+++ b/app/e2e/widget-states.spec.ts
@@ -0,0 +1,592 @@
+import {
+ test,
+ expect,
+ ALICE,
+ createTestDashboard,
+ typeInEditor,
+} from "./fixtures";
+
+test.describe("Widget editor", () => {
+ test.beforeEach(async ({ authPage, page }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ // Create a fresh dashboard to avoid test pollution from other specs.
+ // Await POST before asserting URL to avoid the create-then-wait race.
+ await page.getByRole("button", { name: /New Dashboard/i }).click();
+ const dialog = page.getByRole("dialog", { name: "Create Dashboard" });
+ await dialog.locator("#dashboard-name").fill("Widget States Test");
+ await Promise.all([
+ page.waitForResponse(
+ (r) =>
+ r.url().endsWith("/api/dashboards") &&
+ r.request().method() === "POST" &&
+ r.status() === 201,
+ { timeout: 10_000 },
+ ),
+ dialog.getByRole("button", { name: "Create" }).click(),
+ ]);
+ await page.waitForURL(/\/edit/, { timeout: 15_000 });
+ await expect(page.getByText("Editing:")).toBeVisible();
+ });
+
+ test.describe("uncovered states", () => {
+ test("should show preview error for invalid query", async ({ page }) => {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select connection (Bar Chart is default)
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Enter an invalid query into the CodeMirror editor
+ await typeInEditor(dialog, page, "THIS IS NOT VALID CYPHER !!!");
+
+ // Run the query
+ await expect(
+ dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)"),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)").click();
+
+ // Should show error indicator (icon button with aria-label describing the error)
+ await expect(
+ dialog.getByRole("button", { name: /query failed/i }),
+ ).toBeVisible({ timeout: 15_000 });
+ });
+
+ test("chart type selector shows all chart types", async ({ page }) => {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // The modal should show Connection and Chart Type selectors
+ await expect(
+ dialog.locator("label").filter({ hasText: "Connection" }).first(),
+ ).toBeVisible();
+ await expect(
+ dialog.getByText("Chart Type", { exact: true }),
+ ).toBeVisible();
+
+ // Query editor should be immediately visible
+ await expect(
+ dialog.locator("[data-testid='codemirror-container']"),
+ ).toBeVisible();
+
+ // Open the chart type dropdown (2nd combobox)
+ await dialog.getByRole("combobox").nth(1).click();
+
+ // All standard chart types should be in the dropdown options
+ await expect(
+ page.getByRole("option", { name: "Bar Chart" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("option", { name: "Line Chart" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("option", { name: "Pie Chart" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("option", { name: "Data Table" }),
+ ).toBeVisible();
+ await expect(page.getByRole("option", { name: "Graph" })).toBeVisible();
+ await expect(
+ page.getByRole("option", { name: "Map", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("option", { name: "Single Value" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("option", { name: "JSON Viewer" }),
+ ).toBeVisible();
+ await expect(page.getByRole("option", { name: "Form" })).toBeVisible();
+
+ // v0.8 chart types
+ await expect(page.getByRole("option", { name: "Gauge" })).toBeVisible();
+ await expect(page.getByRole("option", { name: "Sankey" })).toBeVisible();
+ await expect(
+ page.getByRole("option", { name: "Sunburst" }),
+ ).toBeVisible();
+ await expect(page.getByRole("option", { name: "Radar" })).toBeVisible();
+ await expect(page.getByRole("option", { name: "Treemap" })).toBeVisible();
+ await expect(
+ page.getByRole("option", { name: "Markdown" }),
+ ).toBeVisible();
+ await expect(page.getByRole("option", { name: "iFrame" })).toBeVisible();
+
+ // Close by pressing Escape
+ await page.keyboard.press("Escape");
+ });
+ });
+
+ test.describe("actions menu", () => {
+ test("widget card should show actions menu with edit and remove", async ({
+ page,
+ }) => {
+ // Add a widget first
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.title LIMIT 3",
+ );
+
+ await expect(
+ dialog.getByRole("button", { name: "Add Widget" }),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog).not.toBeVisible({ timeout: 10_000 });
+
+ // Open widget actions menu
+ const actionsBtn = page
+ .getByRole("button", { name: "Widget actions" })
+ .last();
+ await expect(actionsBtn).toBeVisible({ timeout: 10_000 });
+ await actionsBtn.click();
+
+ // Should show Edit and Remove menu items
+ await expect(
+ page.getByRole("menuitem", { name: "Edit Widget" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("menuitem", { name: "Remove" }),
+ ).toBeVisible();
+ });
+ });
+});
+
+test.describe("Widget without connection", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("widget without connection shows 'No connection configured'", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+
+ // Create a test dashboard via API
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `No Connection ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+
+ // Add a widget with empty connectionId via the API
+ await page.request.put(`/api/dashboards/${id}`, {
+ data: {
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Main",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "",
+ query: "MATCH (m:Movie) RETURN m.title LIMIT 5",
+ settings: { title: "Broken Widget" },
+ },
+ ],
+ gridLayout: [{ i: "w1", x: 0, y: 0, w: 12, h: 5 }],
+ },
+ ],
+ },
+ },
+ });
+
+ // Navigate to the dashboard (view mode)
+ await page.goto(`/${id}`);
+
+ // Assert "No connection configured" is visible on the widget
+ await expect(page.getByText("No connection configured")).toBeVisible({
+ timeout: 15_000,
+ });
+ });
+});
+
+test.describe("Refresh button", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("widget with showRefreshButton shows refresh button and click re-fetches", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const res = await page.request.post("/api/dashboards", {
+ data: { name: `Refresh ${Date.now()}` },
+ });
+ const { id } = (await res.json()).data;
+ dashboardCleanup = async () => {
+ await page.request.delete(`/api/dashboards/${id}`);
+ };
+
+ await page.request.put(`/api/dashboards/${id}`, {
+ data: {
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Main",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query: "MATCH (m:Movie) RETURN m.title AS title LIMIT 5",
+ settings: {
+ title: "Movies",
+ chartOptions: { showRefreshButton: true },
+ },
+ },
+ ],
+ gridLayout: [{ i: "w1", x: 0, y: 0, w: 12, h: 5 }],
+ },
+ ],
+ },
+ },
+ });
+
+ await page.goto(`/${id}`);
+ await expect(page.getByText("Movies")).toBeVisible({ timeout: 15_000 });
+
+ // Refresh button should be visible in the widget card header
+ const widgetCard = page.getByTestId("widget-card").first();
+ const refreshBtn = widgetCard.getByRole("button", { name: "Refresh" });
+ await expect(refreshBtn).toBeVisible({ timeout: 10_000 });
+
+ // Wait for data to load first
+ await expect(page.locator("td").first()).toBeVisible({ timeout: 15_000 });
+
+ // Click refresh — should trigger a new /api/query request
+ const queryPromise = page.waitForResponse(
+ (resp) => resp.url().includes("/api/query") && resp.status() === 200,
+ { timeout: 15_000 },
+ );
+ await refreshBtn.click();
+ await queryPromise;
+ await expect(page.locator("td").first()).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText("Query Failed")).not.toBeVisible();
+ });
+});
+
+test.describe("Empty result set — No data UX", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ async function createWidgetDashboard(
+ request: import("@playwright/test").APIRequestContext,
+ chartType: string,
+ query: string,
+ ) {
+ const res = await request.post("/api/dashboards", {
+ data: { name: `Empty ${chartType} ${Date.now()}` },
+ });
+ const { id } = (await res.json()).data;
+ await request.put(`/api/dashboards/${id}`, {
+ data: {
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ {
+ id: "w1",
+ chartType,
+ connectionId: "conn-neo4j-001",
+ query,
+ settings: { title: `Empty ${chartType}` },
+ },
+ ],
+ gridLayout: [{ i: "w1", x: 0, y: 0, w: 12, h: 4 }],
+ },
+ ],
+ },
+ },
+ });
+ return {
+ id,
+ cleanup: async () => {
+ await request.delete(`/api/dashboards/${id}`);
+ },
+ };
+ }
+
+ const EMPTY_QUERY = "MATCH (n:NonExistentLabel__E2E) RETURN n.name LIMIT 1";
+
+ test("bar chart with empty result renders without error", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createWidgetDashboard(
+ page.request,
+ "bar",
+ EMPTY_QUERY,
+ );
+ dashboardCleanup = cleanup;
+
+ await page.goto(`/${id}`);
+ const widget = page.locator("[data-testid='widget-card']");
+ await expect(widget).toBeVisible({ timeout: 15_000 });
+ // ECharts renders "No data" on canvas — verify canvas present, no error
+ await expect(widget.locator("canvas")).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText("Query Failed")).not.toBeVisible();
+ await expect(page.getByText("Incompatible data format")).not.toBeVisible();
+ });
+
+ test("table with empty result shows 'No results'", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createWidgetDashboard(
+ page.request,
+ "table",
+ EMPTY_QUERY,
+ );
+ dashboardCleanup = cleanup;
+
+ await page.goto(`/${id}`);
+ // Table widget renders its own empty state ("No results") via DataGrid
+ await expect(page.getByText("No results")).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText("Query Failed")).not.toBeVisible();
+ });
+
+ test("single-value with empty result shows fallback value", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createWidgetDashboard(
+ page.request,
+ "single-value",
+ EMPTY_QUERY,
+ );
+ dashboardCleanup = cleanup;
+
+ await page.goto(`/${id}`);
+ const widget = page.locator("[data-testid='widget-card']");
+ await expect(widget).toBeVisible({ timeout: 15_000 });
+ // Single-value renders "0" as fallback when no data returned
+ await expect(widget.getByText("0")).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByText("Query Failed")).not.toBeVisible();
+ });
+
+ test("pie chart with empty result renders without error", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createWidgetDashboard(
+ page.request,
+ "pie",
+ EMPTY_QUERY,
+ );
+ dashboardCleanup = cleanup;
+
+ await page.goto(`/${id}`);
+ const widget = page.locator("[data-testid='widget-card']");
+ await expect(widget).toBeVisible({ timeout: 15_000 });
+ await expect(widget.locator("canvas")).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText("Query Failed")).not.toBeVisible();
+ await expect(page.getByText("Incompatible data format")).not.toBeVisible();
+ });
+
+ test("graph widget with empty result shows 'No graph data'", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createWidgetDashboard(
+ page.request,
+ "graph",
+ EMPTY_QUERY,
+ );
+ dashboardCleanup = cleanup;
+
+ await page.goto(`/${id}`);
+ await expect(page.getByText("No graph data")).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(page.getByText("Query Failed")).not.toBeVisible();
+ });
+
+ test("empty state is distinct from error state", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createWidgetDashboard(
+ page.request,
+ "table",
+ EMPTY_QUERY,
+ );
+ dashboardCleanup = cleanup;
+
+ await page.goto(`/${id}`);
+ // Table renders DOM-visible "No results" — verifiable text
+ await expect(page.getByText("No results")).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText("Query Failed")).not.toBeVisible();
+ await expect(page.getByText("Incompatible data format")).not.toBeVisible();
+ });
+});
+
+test.describe("Manual run mode", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("widget with manualRun shows overlay and executes on click", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const res = await page.request.post("/api/dashboards", {
+ data: { name: `ManualRun ${Date.now()}` },
+ });
+ const { id } = (await res.json()).data;
+ dashboardCleanup = async () => {
+ await page.request.delete(`/api/dashboards/${id}`);
+ };
+
+ await page.request.put(`/api/dashboards/${id}`, {
+ data: {
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Main",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query: "MATCH (m:Movie) RETURN m.title AS title LIMIT 5",
+ settings: {
+ title: "Manual Table",
+ chartOptions: { manualRun: true },
+ },
+ },
+ ],
+ gridLayout: [{ i: "w1", x: 0, y: 0, w: 12, h: 5 }],
+ },
+ ],
+ },
+ },
+ });
+
+ await page.goto(`/${id}`);
+ await expect(page.getByText("Manual Table")).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Manual-run overlay should be visible
+ const overlay = page.getByTestId("manual-run-overlay");
+ await expect(overlay).toBeVisible({ timeout: 10_000 });
+ await expect(overlay.getByText("Query execution is paused.")).toBeVisible();
+
+ // Click "Run Query"
+ await overlay.getByRole("button", { name: "Run Query" }).click();
+
+ // Overlay should disappear and data should load
+ await expect(overlay).not.toBeVisible({ timeout: 10_000 });
+ await expect(page.locator("td").first()).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText("Query Failed")).not.toBeVisible();
+ });
+});
+
+test.describe("Cache forever mode", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("widget with cacheMode 'forever' shows refresh button even when showRefreshButton is false", async ({
+ authPage,
+ page,
+ }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const res = await page.request.post("/api/dashboards", {
+ data: { name: `CacheForever ${Date.now()}` },
+ });
+ const { id } = (await res.json()).data;
+ dashboardCleanup = async () => {
+ await page.request.delete(`/api/dashboards/${id}`);
+ };
+
+ await page.request.put(`/api/dashboards/${id}`, {
+ data: {
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Main",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "conn-neo4j-001",
+ query: "MATCH (m:Movie) RETURN m.title AS title LIMIT 5",
+ settings: {
+ title: "Forever Cache",
+ chartOptions: {
+ cacheMode: "forever",
+ showRefreshButton: false,
+ },
+ },
+ },
+ ],
+ gridLayout: [{ i: "w1", x: 0, y: 0, w: 12, h: 5 }],
+ },
+ ],
+ },
+ },
+ });
+
+ await page.goto(`/${id}`);
+ await expect(page.getByText("Forever Cache")).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Data should load
+ await expect(page.locator("td").first()).toBeVisible({ timeout: 15_000 });
+
+ // Refresh button should be visible even though showRefreshButton is false
+ const widgetCard = page.getByTestId("widget-card").first();
+ const refreshBtn = widgetCard.getByRole("button", { name: "Refresh" });
+ await expect(refreshBtn).toBeVisible({ timeout: 10_000 });
+
+ // Click refresh — should trigger a new /api/query request
+ const queryPromise = page.waitForResponse(
+ (resp) => resp.url().includes("/api/query") && resp.status() === 200,
+ { timeout: 15_000 },
+ );
+ await refreshBtn.click();
+ await queryPromise;
+ await expect(page.locator("td").first()).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText("Query Failed")).not.toBeVisible();
+ });
+});
diff --git a/app/e2e/widgets.spec.ts b/app/e2e/widgets.spec.ts
new file mode 100644
index 000000000..8b0db60b5
--- /dev/null
+++ b/app/e2e/widgets.spec.ts
@@ -0,0 +1,456 @@
+import {
+ test,
+ expect,
+ ALICE,
+ createTestDashboard,
+ typeInEditor,
+ getPreview,
+} from "./fixtures";
+
+test.describe("Widget creation", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.beforeEach(async ({ authPage, page }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Widget Creation ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+ // Navigate to the new dashboard in edit mode
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+ });
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("should complete widget creation flow", async ({ page }) => {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select connection (Bar Chart is default)
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.title AS label, m.released AS value LIMIT 5",
+ );
+
+ await expect(
+ dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)"),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)").click();
+ // Wait for preview to render
+ await expect(getPreview(dialog)).toBeVisible({
+ timeout: 15000,
+ });
+
+ // Add widget
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog).not.toBeVisible();
+ });
+
+ test("should add a table widget", async ({ page }) => {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+ // Select connection first to avoid CM readonly race (chart type change after
+ // connection is set re-renders the editor but preserves the connectionId state)
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (m:Movie) RETURN m.title, m.released LIMIT 10",
+ );
+
+ await expect(
+ dialog.getByRole("button", { name: "Add Widget" }),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ });
+
+ test("should add a JSON viewer widget", async ({ page }) => {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+ // Select connection first to avoid CM readonly race
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "JSON Viewer" }).click();
+
+ await typeInEditor(dialog, page, "MATCH (m:Movie) RETURN m LIMIT 3");
+
+ await expect(
+ dialog.getByRole("button", { name: "Add Widget" }),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ });
+
+ test("should render table with dot-notation fields (n.name)", async ({
+ page,
+ }) => {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+ // Select connection first to avoid CM readonly race
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+
+ // Use a Cypher query that returns dotted field names.
+ // No LIMIT here — wrapWithPreviewLimit appends LIMIT 25 automatically.
+ // Including LIMIT in the query + wrapWithPreviewLimit = double LIMIT → Cypher error.
+ await typeInEditor(
+ dialog,
+ page,
+ "MATCH (n:Person) RETURN n.name, n.born ORDER BY n.name",
+ );
+
+ await expect(
+ dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)"),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)").click();
+
+ // Wait for the preview to render — should show table data (not empty)
+ await expect(getPreview(dialog)).toBeVisible({
+ timeout: 15_000,
+ });
+ // The table header should contain the dotted key as-is
+ // Scope to to avoid matching the CM editor content which also contains "n.name"
+ await expect(
+ dialog.locator("th").filter({ hasText: "n.name" }),
+ ).toBeVisible({ timeout: 10_000 });
+ // And the table should contain at least one data row
+ await expect(dialog.locator("tbody tr").first()).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ // Flaky: CM6 __cmView not available (readonly) — typeInEditor timing in CI
+ test.fixme("should add a PostgreSQL widget and preview data", async ({
+ page,
+ }) => {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+ // Select PG connection first to avoid CM readonly race
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option", { name: /PostgreSQL/ }).click();
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+
+ await typeInEditor(
+ dialog,
+ page,
+ "SELECT title, released FROM movies LIMIT 5",
+ );
+
+ await expect(
+ dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)"),
+ ).toBeEnabled({ timeout: 10_000 });
+ await dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)").click();
+
+ // Wait for preview
+ await expect(getPreview(dialog)).toBeVisible({
+ timeout: 15_000,
+ });
+ // Should contain a movie title from the seed data
+ await expect(
+ dialog
+ .getByText("The Matrix", { exact: true })
+ .or(dialog.getByText("Top Gun")),
+ ).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("modal shows connection and chart type selectors", async ({ page }) => {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // The new single-view modal should show both Connection and Chart Type selectors
+ await expect(
+ dialog.locator("label").filter({ hasText: "Connection" }).first(),
+ ).toBeVisible();
+ await expect(dialog.getByText("Chart Type", { exact: true })).toBeVisible();
+
+ // Query editor should be immediately visible
+ await expect(
+ dialog.locator("[data-testid='codemirror-container']"),
+ ).toBeVisible();
+ });
+
+ test("connector combobox filters results by typed text", async ({ page }) => {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Open the connection combobox (first combobox in dialog)
+ await dialog.getByRole("combobox").nth(0).click();
+ // Type a partial name that exists (e.g. "neo" matches the Neo4j connection)
+ await page.getByPlaceholder("Search connections...").fill("neo");
+ // At least one matching option should be visible
+ await expect(page.getByRole("option").first()).toBeVisible({
+ timeout: 5_000,
+ });
+ // Non-matching connections should not appear; pick the first visible option
+ await page.getByRole("option").first().click();
+ // Run button should be visible (no Next step anymore)
+ await expect(
+ dialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)"),
+ ).toBeVisible();
+ });
+});
+
+test.describe("Widget edit – query cache invalidation", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.beforeEach(async ({ authPage, page }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Widget Edit ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+ });
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("re-fetches query data after editing widget with changed query", async ({
+ page,
+ }) => {
+ // Set up response waiter BEFORE the action that triggers the request to
+ // avoid a race condition where the response arrives before waitForResponse
+ // starts listening.
+ const firstQuery =
+ "MATCH (m:Movie) RETURN m.title AS label, m.released AS value LIMIT 3";
+ const secondQuery =
+ "MATCH (p:Person) RETURN p.name AS label, p.born AS value LIMIT 3";
+
+ // Step 1: add a widget via the dialog
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ await typeInEditor(dialog, page, firstQuery);
+
+ // Set up response waiter BEFORE closing the dialog — the widget will
+ // fetch its query data as soon as it mounts on the dashboard grid.
+ const initialFetch = page.waitForResponse(
+ (res) => res.url().includes("/api/query") && res.status() === 200,
+ { timeout: 30_000 },
+ );
+
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog).not.toBeVisible({ timeout: 10_000 });
+
+ // Confirm the card fetched its initial data
+ await initialFetch;
+
+ // Wait for the widget card to fully render before opening edit
+ const actionsBtn = page
+ .getByRole("button", { name: "Widget actions" })
+ .last();
+ await expect(actionsBtn).toBeVisible({ timeout: 15_000 });
+
+ // Step 2: open edit modal and save with a different query
+ const refetch = page.waitForResponse(
+ (res) => res.url().includes("/api/query") && res.status() === 200,
+ { timeout: 15_000 },
+ );
+
+ await actionsBtn.click();
+ await page.getByRole("menuitem", { name: "Edit Widget" }).click();
+
+ const editDialog = page.getByRole("dialog", { name: "Edit Widget" });
+ await expect(editDialog).toBeVisible({ timeout: 10_000 });
+
+ // Use the Clear query button to reliably reset CodeMirror state
+ // (Ctrl+A + insertText doesn't reliably update the React-controlled CM value)
+ await editDialog.getByRole("button", { name: "Clear query" }).click();
+ await typeInEditor(editDialog, page, secondQuery);
+
+ await editDialog.getByRole("button", { name: "Save Changes" }).click();
+ await expect(editDialog).not.toBeVisible();
+
+ // The query key changed (different query string), so the card must re-fetch
+ await refetch;
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Widget duplicate and fullscreen
+// ---------------------------------------------------------------------------
+
+test.describe("Widget duplicate", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.beforeEach(async ({ authPage, page }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Widget Dup ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+ });
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("should duplicate a widget via actions menu", async ({ page }) => {
+ test.setTimeout(60_000);
+
+ // Add a widget
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ await typeInEditor(dialog, page, "MATCH (m:Movie) RETURN m.title LIMIT 3");
+ await dialog.getByRole("button", { name: "Add Widget" }).click();
+ await expect(dialog).not.toBeVisible({ timeout: 5_000 });
+
+ // Should have 1 widget
+ await expect(page.locator("[data-testid='widget-card']")).toHaveCount(1, {
+ timeout: 10_000,
+ });
+
+ // Open widget actions and click Duplicate
+ const actionsBtn = page
+ .getByRole("button", { name: "Widget actions" })
+ .last();
+ await expect(actionsBtn).toBeVisible({ timeout: 10_000 });
+ await actionsBtn.click();
+ await page.getByRole("menuitem", { name: "Duplicate" }).click();
+
+ // Should now have 2 widget cards
+ await expect(page.locator("[data-testid='widget-card']")).toHaveCount(2, {
+ timeout: 10_000,
+ });
+ });
+});
+
+test.describe("Widget editor UX", () => {
+ let dashboardCleanup: (() => Promise) | undefined;
+
+ test.beforeEach(async ({ authPage, page }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ const { id, cleanup } = await createTestDashboard(
+ page.request,
+ `Widget Editor UX ${Date.now()}`,
+ );
+ dashboardCleanup = cleanup;
+ await page.goto(`/${id}/edit`);
+ await expect(page.getByText("Editing:")).toBeVisible();
+ });
+
+ test.afterEach(async () => {
+ await dashboardCleanup?.();
+ });
+
+ test("should show no-connector warning when connection not selected", async ({
+ page,
+ }) => {
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select chart type "Data Table" but do NOT select a connection
+ await dialog.getByRole("combobox").nth(1).click();
+ await page.getByRole("option", { name: "Data Table" }).click();
+
+ // Warning should NOT show until user types a query
+ const warning = dialog.getByTestId("no-connector-warning");
+ await expect(warning).not.toBeVisible({ timeout: 2_000 });
+
+ // Type a query without selecting a connection — warning should appear
+ await typeInEditor(dialog, page, "SELECT 1");
+ await expect(warning).toBeVisible({ timeout: 5_000 });
+ await expect(warning).toContainText("Select a connection");
+
+ // Now select a connection (first in dropdown)
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Assert warning disappears
+ await expect(warning).not.toBeVisible({ timeout: 5_000 });
+ });
+
+ test("should auto-preview when connection and query are set in add mode", async ({
+ page,
+ }) => {
+ test.setTimeout(60_000);
+
+ await page.getByRole("button", { name: "Add Widget" }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add Widget" });
+
+ // Select Neo4j connection
+ await dialog.getByRole("combobox").nth(0).click();
+ await page.getByRole("option").first().click();
+
+ // Enter query via typeInEditor helper — do NOT click Run button
+ await typeInEditor(dialog, page, "MATCH (m:Movie) RETURN m.title LIMIT 5");
+
+ // Wait for auto-preview to fire and render data
+ // The preview pane should show data without explicitly clicking Run
+ await expect(getPreview(dialog)).toBeVisible({ timeout: 15_000 });
+ });
+});
+
+test.describe("Widget fullscreen", () => {
+ test("should open fullscreen dialog and render chart", async ({
+ authPage,
+ page,
+ }) => {
+ test.setTimeout(60_000);
+ await authPage.login(ALICE.email, ALICE.password);
+
+ // Navigate to "Movie Analytics" view mode
+ await page.getByText("Movie Analytics", { exact: true }).click();
+ await page.waitForURL(/\/[\w-]+$/, { timeout: 10_000 });
+
+ // Wait for widgets to load
+ await expect(
+ page.locator("[data-testid='widget-card']").first(),
+ ).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Click fullscreen button (sr-only text "Fullscreen")
+ const fullscreenBtn = page
+ .getByRole("button", { name: "Fullscreen" })
+ .first();
+ await expect(fullscreenBtn).toBeVisible({ timeout: 10_000 });
+ await fullscreenBtn.click();
+
+ // A dialog should open with the chart rendered
+ await expect(page.locator("[role='dialog']")).toBeVisible({
+ timeout: 5_000,
+ });
+ // The fullscreen dialog should contain a chart (canvas or table)
+ await expect(
+ page
+ .locator("[role='dialog'] canvas")
+ .or(page.locator("[role='dialog'] table"))
+ .first(),
+ ).toBeVisible({ timeout: 10_000 });
+
+ // Close with Escape
+ await page.keyboard.press("Escape");
+ await expect(page.locator("[role='dialog']")).not.toBeVisible({
+ timeout: 5_000,
+ });
+ });
+});
diff --git a/app/e2e/write-permissions.spec.ts b/app/e2e/write-permissions.spec.ts
new file mode 100644
index 000000000..a967e6787
--- /dev/null
+++ b/app/e2e/write-permissions.spec.ts
@@ -0,0 +1,315 @@
+import { test, expect, ALICE, TEST_PG_PORT } from "./fixtures";
+import { AuthPage } from "./pages/auth";
+import type { Browser, Page } from "@playwright/test";
+
+/**
+ * Covers issue #478 — Form widget write-permission enforcement.
+ *
+ * These tests complement form-widget.spec.ts rather than duplicate it.
+ * form-widget.spec.ts already covers:
+ * - happy-path submit (Alice admin → success message)
+ * - creator with canWrite=false, UI click → "Write permission required"
+ *
+ * What's NEW here:
+ * 1. Reader role denied at API (no reader test exists anywhere)
+ * 2. canWrite=false creator denied at API (direct POST, complements the UI-only existing test)
+ * 3. canWrite toggle propagates to active sessions without re-login
+ * 4. Write query runtime errors return a safe user-facing message
+ *
+ * Implementation notes:
+ * - Each test uses the built-in `page` fixture as the admin session (already
+ * a fresh browser context, no extra login traffic) and creates ONE extra
+ * context for the ad-hoc creator/reader. Keeping the extra-context count
+ * low matters: every extra /login navigation adds load to the dev server
+ * and can destabilize the existing hydration race in AuthPage.login.
+ * - Ad-hoc users are created via POST /api/users (admin) for isolation from
+ * tests that rely on BOB's state.
+ * - The canWrite check in /api/query/write (route.ts:27-29) runs BEFORE the
+ * connection ownership check (line 38-52), so denial tests can hit the
+ * write endpoint with any connection id — no per-user connection setup
+ * needed for denial paths.
+ * - NextAuth's jwt callback (lib/auth/config.ts:99-126) re-fetches role and
+ * canWrite from the DB on every token refresh, so a live PATCH propagates
+ * to active sessions immediately. Test 3 verifies that property.
+ */
+
+async function newSessionAs(
+ browser: Browser,
+ email: string,
+ password: string,
+): Promise<{ page: Page; close: () => Promise }> {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await new AuthPage(page).login(email, password);
+ return { page, close: () => context.close() };
+}
+
+/**
+ * Admin helper: create an ad-hoc user and return a cleanup function.
+ * The returned cleanup calls DELETE /api/users/{id} as admin.
+ */
+async function createAdHocUser(
+ adminPage: Page,
+ {
+ role,
+ canWrite,
+ }: {
+ role: "creator" | "reader";
+ canWrite: boolean;
+ },
+): Promise<{
+ id: string;
+ email: string;
+ password: string;
+ cleanup: () => Promise;
+}> {
+ const timestamp = Date.now();
+ const suffix = Math.random().toString(36).slice(2, 8);
+ const email = `${role}-${timestamp}-${suffix}@example.com`;
+ const password = "password123";
+
+ const res = await adminPage.request.post("/api/users", {
+ data: {
+ name: `E2E ${role} ${suffix}`,
+ email,
+ password,
+ role,
+ canWrite,
+ },
+ });
+ if (!res.ok()) {
+ throw new Error(
+ `createAdHocUser(${role}) failed: ${res.status()} ${await res.text()}`,
+ );
+ }
+ const { data: user } = await res.json();
+ return {
+ id: user.id as string,
+ email,
+ password,
+ cleanup: async () => {
+ await adminPage.request.delete(`/api/users/${user.id}`);
+ },
+ };
+}
+
+test.describe("Form widget — write permission enforcement", () => {
+ test.describe.configure({ timeout: 60_000 });
+
+ // Every test logs in as Alice on the built-in `page` fixture, so that
+ // context serves as the admin session without allocating a second browser
+ // context just to hold admin cookies. AuthPage.login handles the
+ // pre-hydration submit race upstream (see app/e2e/pages/auth.ts).
+ test.beforeEach(async ({ authPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ });
+
+ test("1. reader role is denied at /api/query/write (403)", async ({
+ page,
+ browser,
+ }) => {
+ const reader = await createAdHocUser(page, {
+ role: "reader",
+ canWrite: false,
+ });
+
+ const readerSession = await newSessionAs(
+ browser,
+ reader.email,
+ reader.password,
+ );
+ try {
+ // The canWrite check fires BEFORE the connection ownership check
+ // (app/src/app/api/query/write/route.ts:27-29), so any connection id
+ // produces the same 403 for a reader.
+ const res = await readerSession.page.request.post("/api/query/write", {
+ data: {
+ connectionId: "conn-neo4j-001",
+ query: "CREATE (n:ReaderTest) RETURN n",
+ },
+ });
+ expect(res.status()).toBe(403);
+
+ const body = await res.json();
+ expect(body.error?.message).toMatch(/write permission required/i);
+ } finally {
+ await readerSession.close();
+ await reader.cleanup();
+ }
+ });
+
+ test("2. creator with canWrite=false is denied at /api/query/write (403)", async ({
+ page,
+ browser,
+ }) => {
+ // Create the creator already disabled so the first login carries the
+ // correct JWT claim — no re-login dance needed for this test.
+ const creator = await createAdHocUser(page, {
+ role: "creator",
+ canWrite: false,
+ });
+
+ const creatorSession = await newSessionAs(
+ browser,
+ creator.email,
+ creator.password,
+ );
+ try {
+ const res = await creatorSession.page.request.post("/api/query/write", {
+ data: {
+ connectionId: "conn-pg-001",
+ query: "INSERT INTO movies (title) VALUES ('x')",
+ },
+ });
+ expect(res.status()).toBe(403);
+
+ const body = await res.json();
+ expect(body.error?.message).toMatch(/write permission required/i);
+ } finally {
+ await creatorSession.close();
+ await creator.cleanup();
+ }
+ });
+
+ test("3. canWrite toggle propagates to active sessions without re-login", async ({
+ page,
+ browser,
+ }) => {
+ // This test pins an important security property: when an admin disables
+ // canWrite on a creator, the creator's ACTIVE session stops being able to
+ // write *immediately* — no re-login, no session expiry, no page reload.
+ // Without this, a compromised or misbehaving user could keep writing long
+ // after their permission was revoked.
+ //
+ // The mechanism lives in lib/auth/config.ts:99-126 — the jwt callback
+ // re-fetches role/canWrite from the DB on every token refresh, so the
+ // value in session.user.canWrite always matches the DB row.
+
+ const creator = await createAdHocUser(page, {
+ role: "creator",
+ canWrite: true,
+ });
+
+ const creatorSession = await newSessionAs(
+ browser,
+ creator.email,
+ creator.password,
+ );
+ let connectionId: string | null = null;
+
+ try {
+ // Creator needs to own a connection so /api/query/write reaches the
+ // executor rather than hitting the 404 connection-ownership branch.
+ const connRes = await creatorSession.page.request.post(
+ "/api/connections",
+ {
+ data: {
+ name: `write-perm-test-${Date.now()}`,
+ type: "postgresql",
+ config: {
+ uri: `postgresql://localhost:${TEST_PG_PORT}`,
+ username: "neoboard",
+ password: "neoboard",
+ database: "movies",
+ },
+ },
+ },
+ );
+ expect(connRes.status()).toBe(201);
+ connectionId = (await connRes.json()).data.id as string;
+
+ // Step 1: creator has canWrite=true → harmless SELECT succeeds inside
+ // the WRITE transaction.
+ const pre = await creatorSession.page.request.post("/api/query/write", {
+ data: { connectionId, query: "SELECT 1 AS ok" },
+ });
+ expect(pre.status()).toBe(200);
+
+ // Step 2: admin revokes canWrite via the users API.
+ const patchRes = await page.request.patch(`/api/users/${creator.id}`, {
+ data: { canWrite: false },
+ });
+ expect(patchRes.ok()).toBeTruthy();
+ expect((await patchRes.json()).data.canWrite).toBe(false);
+
+ // Step 3: the SAME creator session — no re-login, no cookie refresh —
+ // must now be denied. This is the security-critical assertion.
+ const post = await creatorSession.page.request.post("/api/query/write", {
+ data: { connectionId, query: "SELECT 1 AS ok" },
+ });
+ expect(post.status()).toBe(403);
+ expect((await post.json()).error?.message).toMatch(
+ /write permission required/i,
+ );
+ } finally {
+ if (connectionId) {
+ await creatorSession.page.request
+ .delete(`/api/connections/${connectionId}`)
+ .catch(() => undefined);
+ }
+ await creatorSession.close();
+ await creator.cleanup();
+ }
+ });
+
+ test("4. write query runtime error returns safe 500 message", async ({
+ page,
+ browser,
+ }) => {
+ const creator = await createAdHocUser(page, {
+ role: "creator",
+ canWrite: true,
+ });
+
+ const creatorSession = await newSessionAs(
+ browser,
+ creator.email,
+ creator.password,
+ );
+ let connectionId: string | null = null;
+
+ try {
+ const connRes = await creatorSession.page.request.post(
+ "/api/connections",
+ {
+ data: {
+ name: `runtime-error-test-${Date.now()}`,
+ type: "postgresql",
+ config: {
+ uri: `postgresql://localhost:${TEST_PG_PORT}`,
+ username: "neoboard",
+ password: "neoboard",
+ database: "movies",
+ },
+ },
+ },
+ );
+ expect(connRes.status()).toBe(201);
+ connectionId = (await connRes.json()).data.id as string;
+
+ // Intentionally broken SQL — the executor will throw; the route should
+ // translate that into a 500 with a user-safe message and NOT leak the
+ // raw driver error.
+ const res = await creatorSession.page.request.post("/api/query/write", {
+ data: {
+ connectionId,
+ query: "THIS IS NOT VALID SQL",
+ },
+ });
+ expect(res.status()).toBe(500);
+
+ const body = await res.json();
+ expect(body.error?.message).toBe("Write query execution failed");
+ // Safety check: raw driver syntax errors must not bleed through.
+ expect(body.error?.message).not.toMatch(/syntax error at or near/i);
+ } finally {
+ if (connectionId) {
+ await creatorSession.page.request
+ .delete(`/api/connections/${connectionId}`)
+ .catch(() => undefined);
+ }
+ await creatorSession.close();
+ await creator.cleanup();
+ }
+ });
+});
diff --git a/app/next-env.d.ts b/app/next-env.d.ts
new file mode 100644
index 000000000..9edff1c7c
--- /dev/null
+++ b/app/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/app/next.config.ts b/app/next.config.ts
new file mode 100644
index 000000000..f20e46aeb
--- /dev/null
+++ b/app/next.config.ts
@@ -0,0 +1,138 @@
+import type { NextConfig } from "next";
+import { resolve, sep } from "path";
+
+// Canonicalise all mobx imports to the single copy installed under component/
+// to prevent the "multiple mobx instances" MobX warning when @neo4j-nvl
+// is transpiled via transpilePackages.
+const mobxPath = resolve(
+ import.meta.dirname,
+ "..",
+ "component",
+ "node_modules",
+ "mobx",
+);
+
+const componentSrc = resolve(import.meta.dirname, "..", "component", "src");
+
+const nextConfig: NextConfig = {
+ output: "standalone",
+ turbopack: {
+ resolveAlias: {
+ mobx: mobxPath,
+ },
+ },
+ // Enable source maps in production for E2E coverage collection (nextcov).
+ productionBrowserSourceMaps: process.env.E2E_COVERAGE === "1",
+ outputFileTracingRoot: resolve(import.meta.dirname, ".."),
+ transpilePackages: ["@neoboard/components", "@neoboard/connection"],
+ serverExternalPackages: [
+ "postgres",
+ "pg",
+ "neo4j-driver",
+ "neo4j-driver-core",
+ ],
+ async headers() {
+ return [
+ {
+ source: "/:path*",
+ headers: [
+ { key: "X-Frame-Options", value: "DENY" },
+ { key: "X-Content-Type-Options", value: "nosniff" },
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
+ {
+ key: "Permissions-Policy",
+ value: "camera=(), microphone=(), geolocation=()",
+ },
+ {
+ key: "Strict-Transport-Security",
+ value: "max-age=31536000; includeSubDomains",
+ },
+ ],
+ },
+ ];
+ },
+ webpack: (config, { isServer }) => {
+ if (isServer) {
+ // The instrumentation file is compiled in a separate webpack pass that does
+ // not always honour serverExternalPackages. Explicitly mark postgres as an
+ // external so webpack never tries to bundle its Node.js built-in imports
+ // (net, tls, stream, crypto) in that compilation.
+ const prev = config.externals;
+ config.externals = Array.isArray(prev)
+ ? [...prev, "postgres", "pg"]
+ : prev
+ ? [prev, "postgres", "pg"]
+ : ["postgres", "pg"];
+ }
+
+ // Enable full source maps for E2E coverage collection.
+ if (process.env.E2E_COVERAGE === "1") {
+ config.devtool = "source-map";
+ }
+
+ // Canonicalise mobx to a single instance to avoid MobX "multiple instances" warning.
+ config.resolve.alias = {
+ ...(config.resolve.alias ?? {}),
+ mobx: mobxPath,
+ };
+
+ // When transpilePackages includes @neoboard/components, webpack resolves
+ // the component library's bare imports (echarts, @neo4j-nvl, etc.) from
+ // the app's node_modules context. In CI, each package runs `npm ci` in
+ // isolation, so deps installed only in component/node_modules/ aren't
+ // visible to the app's webpack resolver. Adding component/node_modules
+ // to resolve.modules fixes this without duplicating dependencies.
+ config.resolve.modules = [
+ ...(config.resolve.modules ?? []),
+ resolve(import.meta.dirname, "..", "component", "node_modules"),
+ "node_modules",
+ ];
+
+ // The component library uses @/ as a path alias pointing to its own src/.
+ // The app also uses @/ (via tsconfig paths) pointing to app/src/.
+ // We need to resolve @/ differently based on which package the import originates from.
+ config.resolve.plugins = config.resolve.plugins || [];
+ config.resolve.plugins.push({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ apply(resolver: any) {
+ const target = resolver.ensureHook("resolve");
+ resolver.getHook("described-resolve").tapAsync(
+ "ComponentLibraryAlias",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (request: any, resolveContext: any, callback: any) => {
+ const innerRequest = request.request;
+ if (!innerRequest || !innerRequest.startsWith("@/")) {
+ return callback();
+ }
+
+ // Only intercept imports from files inside the component library
+ const issuer = request.context?.issuer || "";
+ const componentMarker = `${sep}component${sep}src${sep}`;
+ if (!issuer.includes(componentMarker)) {
+ return callback();
+ }
+
+ // Rewrite @/ to point to component/src/
+ const relativePath = innerRequest.slice(2); // strip "@/"
+ const obj = {
+ ...request,
+ request: resolve(componentSrc, relativePath),
+ };
+
+ return resolver.doResolve(
+ target,
+ obj,
+ `Resolved @/ for component library`,
+ resolveContext,
+ callback,
+ );
+ },
+ );
+ },
+ });
+
+ return config;
+ },
+};
+
+export default nextConfig;
diff --git a/app/package.json b/app/package.json
new file mode 100644
index 000000000..2c3246e4e
--- /dev/null
+++ b/app/package.json
@@ -0,0 +1,80 @@
+{
+ "name": "@neoboard/app",
+ "version": "2.0.0",
+ "private": true,
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/alfredo1996/neoboard.git"
+ },
+ "scripts": {
+ "predev": "node ../scripts/generate-plugin-imports.mjs",
+ "prebuild": "node ../scripts/generate-plugin-imports.mjs",
+ "dev": "next dev --turbopack",
+ "build": "next build --webpack",
+ "start": "next start",
+ "lint": "next lint",
+ "db:generate": "drizzle-kit generate",
+ "db:migrate": "drizzle-kit migrate",
+ "db:studio": "drizzle-kit studio",
+ "db:lint": "node scripts/lint-migrations.mjs",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "test:e2e": "npx playwright test",
+ "test:e2e:ui": "npx playwright test --ui",
+ "test:e2e:coverage": "E2E_COVERAGE=1 npx playwright test",
+ "test:e2e:mock": "npx playwright test --config playwright.mock.config.ts"
+ },
+ "dependencies": {
+ "@auth/drizzle-adapter": "^1.7.4",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@neoboard/components": "*",
+ "@neoboard/connection": "*",
+ "@tanstack/react-query": "^5.96.2",
+ "bcryptjs": "^3.0.2",
+ "drizzle-orm": "^0.45.2",
+ "lucide-react": "^0.564.0",
+ "next": "^16.2.2",
+ "next-auth": "^5.0.0-beta.31",
+ "pg": "^8.20.0",
+ "pino": "^9.5.0",
+ "pino-roll": "^3.0.0",
+ "postgres": "^3.4.9",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "tailwind-merge": "^3.4.0",
+ "zod": "^3.24.2",
+ "zustand": "^5.0.12"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.59.1",
+ "@tanstack/react-table": "^8.21.3",
+ "@testcontainers/postgresql": "^11.12.0",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/bcryptjs": "^3.0.0",
+ "@types/node": "^22.15.0",
+ "@types/react": "^19.2.5",
+ "@types/react-dom": "^19.2.3",
+ "@vitest/coverage-v8": "^4.0.18",
+ "autoprefixer": "^10.4.20",
+ "dotenv": "^17.4.1",
+ "drizzle-kit": "^0.31.10",
+ "jsdom": "^28.1.0",
+ "nextcov": "^1.2.1",
+ "pino-pretty": "^13.0.0",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.17",
+ "tailwindcss-animate": "^1.0.7",
+ "testcontainers": "^11.11.0",
+ "typescript": "^5.9.3",
+ "vite-tsconfig-paths": "^6.1.1",
+ "vitest": "^4.0.18"
+ }
+}
diff --git a/app/playwright.config.ts b/app/playwright.config.ts
new file mode 100644
index 000000000..a7edd2e0b
--- /dev/null
+++ b/app/playwright.config.ts
@@ -0,0 +1,62 @@
+import { defineConfig, devices } from "@playwright/test";
+
+// global-setup writes TEST_SERVER_PORT to process.env before tests run.
+const serverPort = process.env.TEST_SERVER_PORT || "3100";
+
+/** Nextcov coverage config — read by loadNextcovConfig() in global-setup/teardown. */
+export const nextcov: import("nextcov").NextcovConfig = {
+ buildDir: ".next",
+ outputDir: "coverage-e2e",
+ sourceRoot: "./src",
+ include: ["src/**/*.{ts,tsx}"],
+ exclude: ["src/**/__tests__/**", "src/**/*.test.ts"],
+ reporters: ["lcov", "json", "text-summary"],
+ collectServer: true,
+};
+
+export default defineConfig({
+ testDir: "./e2e",
+ testMatch: "**/*.spec.ts",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: 1,
+ // CI: 2 workers (constrained runner resources).
+ // Locally: 6 workers — balances parallelism with server/DB contention.
+ // Override with --workers=N on the CLI for experimentation.
+ workers: process.env.CI ? 2 : 6,
+ // CI: github (PR annotations) + list (real-time stream) + blob (for cross-shard merge).
+ // Local: interactive HTML report.
+ reporter: process.env.CI ? [["github"], ["list"], ["blob"]] : "html",
+ // Production build eliminates cold-start compilation — tighter timeouts are safe.
+ timeout: 30_000,
+ expect: { timeout: 5_000 },
+
+ globalSetup: "./e2e/global-setup.ts",
+ globalTeardown: "./e2e/global-teardown.ts",
+
+ use: {
+ baseURL: `http://localhost:${serverPort}`,
+ trace: "on-first-retry",
+ screenshot: "only-on-failure",
+ navigationTimeout: 15_000,
+ actionTimeout: 10_000,
+ // Force a fixed, generously-sized viewport for the whole suite. The
+ // default Desktop Chrome viewport is 1280×720; tall modal forms (e.g.
+ // the connection editor with all advanced settings open) push their
+ // submit buttons below the fold and Playwright's "scroll into view"
+ // racing with Radix Dialog's own scroll container leaves clicks
+ // unresolved. 1280×1024 fits every dialog in the suite without
+ // changing per-test code, and never auto-resizes during a run.
+ viewport: { width: 1280, height: 1024 },
+ },
+
+ projects: [
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"],
+ viewport: { width: 1280, height: 1024 },
+ },
+ },
+ ],
+});
diff --git a/app/postcss.config.js b/app/postcss.config.js
new file mode 100644
index 000000000..5c0210ad0
--- /dev/null
+++ b/app/postcss.config.js
@@ -0,0 +1,9 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
+
+module.exports = config;
diff --git a/app/public/logo.svg b/app/public/logo.svg
new file mode 100644
index 000000000..25fa771ea
--- /dev/null
+++ b/app/public/logo.svg
@@ -0,0 +1,6 @@
+
+
+ NB
+
+
+
diff --git a/app/public/og-image.svg b/app/public/og-image.svg
new file mode 100644
index 000000000..6980210ba
--- /dev/null
+++ b/app/public/og-image.svg
@@ -0,0 +1,10 @@
+
+
+
+ NB
+ NeoBoard
+ Open-source dashboards for Neo4j + PostgreSQL
+ The modern alternative to NeoDash
+
+ github.com/alfredo1996/neoboard
+
diff --git a/app/public/site.webmanifest b/app/public/site.webmanifest
new file mode 100644
index 000000000..1db03991d
--- /dev/null
+++ b/app/public/site.webmanifest
@@ -0,0 +1,15 @@
+{
+ "name": "NeoBoard",
+ "short_name": "NeoBoard",
+ "description": "Open-source dashboards for Neo4j + PostgreSQL",
+ "icons": [
+ {
+ "src": "/logo.svg",
+ "sizes": "any",
+ "type": "image/svg+xml"
+ }
+ ],
+ "theme_color": "#3b82f6",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/app/scripts/lint-migrations.mjs b/app/scripts/lint-migrations.mjs
new file mode 100644
index 000000000..9353135c7
--- /dev/null
+++ b/app/scripts/lint-migrations.mjs
@@ -0,0 +1,114 @@
+#!/usr/bin/env node
+
+/**
+ * Lint migration SQL files for non-idempotent DDL patterns.
+ *
+ * Checks:
+ * 1. Bare CREATE TABLE (missing IF NOT EXISTS)
+ * 2. Bare CREATE TYPE (not wrapped in EXCEPTION WHEN duplicate_object)
+ * 3. Bare ALTER TABLE ... ADD COLUMN (not wrapped in DO $$ IF NOT EXISTS)
+ * 4. Bare ALTER TABLE ... ADD CONSTRAINT (not wrapped in DO $$ IF NOT EXISTS)
+ *
+ * Exit code 0 = all migrations are idempotent.
+ * Exit code 1 = violations found.
+ */
+
+import { readFileSync, readdirSync } from "node:fs";
+import { join, basename } from "node:path";
+
+const MIGRATIONS_DIR = join(
+ import.meta.dirname,
+ "..",
+ "drizzle",
+ "migrations"
+);
+
+const violations = [];
+
+const sqlFiles = readdirSync(MIGRATIONS_DIR)
+ .filter((f) => f.endsWith(".sql"))
+ .sort();
+
+for (const file of sqlFiles) {
+ const filePath = join(MIGRATIONS_DIR, file);
+ const content = readFileSync(filePath, "utf-8");
+ const lines = content.split("\n");
+
+ // Check if the entire migration is wrapped in a DO $$ block
+ const isWrappedInDoBlock = /^\s*DO\s+\$\$/m.test(content);
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const lineNum = i + 1;
+
+ // Skip comments and statement breakpoints
+ if (line.trim().startsWith("--") || line.trim() === "") continue;
+
+ // 1. Bare CREATE TABLE without IF NOT EXISTS
+ if (
+ /CREATE\s+TABLE\b/i.test(line) &&
+ !/IF\s+NOT\s+EXISTS/i.test(line) &&
+ !isWrappedInDoBlock
+ ) {
+ violations.push({
+ file,
+ line: lineNum,
+ pattern: "CREATE TABLE without IF NOT EXISTS (outside DO $$ block)",
+ text: line.trim(),
+ });
+ }
+
+ // 2. Bare CREATE TYPE outside a DO $$ EXCEPTION block
+ if (/CREATE\s+TYPE\b/i.test(line) && !isWrappedInDoBlock) {
+ violations.push({
+ file,
+ line: lineNum,
+ pattern:
+ "CREATE TYPE outside DO $$ block (needs EXCEPTION WHEN duplicate_object)",
+ text: line.trim(),
+ });
+ }
+
+ // 3. Bare ALTER TABLE ... ADD COLUMN outside a DO $$ block
+ if (/ALTER\s+TABLE\b.*\bADD\s+COLUMN\b/i.test(line) && !isWrappedInDoBlock) {
+ violations.push({
+ file,
+ line: lineNum,
+ pattern: "ALTER TABLE ADD COLUMN outside DO $$ block (needs IF NOT EXISTS guard)",
+ text: line.trim(),
+ });
+ }
+
+ // 4. Bare ALTER TABLE ... ADD CONSTRAINT outside a DO $$ block
+ if (
+ /ALTER\s+TABLE\b.*\bADD\s+CONSTRAINT\b/i.test(line) &&
+ !isWrappedInDoBlock
+ ) {
+ violations.push({
+ file,
+ line: lineNum,
+ pattern:
+ "ALTER TABLE ADD CONSTRAINT outside DO $$ block (needs IF NOT EXISTS guard)",
+ text: line.trim(),
+ });
+ }
+ }
+}
+
+if (violations.length === 0) {
+ console.log(`✓ All ${sqlFiles.length} migration files are idempotent.`);
+ process.exit(0);
+} else {
+ console.error(
+ `✗ Found ${violations.length} non-idempotent pattern(s) in migration files:\n`
+ );
+ for (const v of violations) {
+ console.error(` ${v.file}:${v.line}`);
+ console.error(` Pattern: ${v.pattern}`);
+ console.error(` Line: ${v.text}\n`);
+ }
+ console.error(
+ "Wrap DDL in idempotent guards. Use DO $$ IF NOT EXISTS blocks for CREATE/ALTER statements."
+ );
+ process.exit(1);
+}
diff --git a/app/src/__tests__/helpers/drizzle-mocks.ts b/app/src/__tests__/helpers/drizzle-mocks.ts
new file mode 100644
index 000000000..0076a6285
--- /dev/null
+++ b/app/src/__tests__/helpers/drizzle-mocks.ts
@@ -0,0 +1,62 @@
+/**
+ * Shared Drizzle ORM query chain builder stubs for API route tests.
+ *
+ * These simulate the chainable API of Drizzle's select/insert/update/delete
+ * builders so tests can control what the "database" returns.
+ */
+
+/** Chainable select builder that resolves to `rows`. Supports from/where/innerJoin/leftJoin/limit/orderBy/offset. */
+export function makeSelectChain(rows: unknown[]) {
+ const resolved = Promise.resolve(rows);
+ const c = Object.assign(resolved, {
+ from: () => c,
+ where: () => c,
+ innerJoin: () => c,
+ leftJoin: () => c,
+ limit: () => c,
+ orderBy: () => c,
+ offset: () => c,
+ });
+ return c;
+}
+
+/** Chainable insert builder. Resolves `returning()` to `returning` array. Supports onConflictDoUpdate/onConflictDoNothing. */
+export function makeInsertChain(returning: unknown[] = []) {
+ const c = {
+ values: () => c,
+ onConflictDoUpdate: () => c,
+ onConflictDoNothing: () => c,
+ returning: () => Promise.resolve(returning),
+ };
+ return c;
+}
+
+/** Chainable update builder type with thenable + chain methods. */
+interface UpdateChain extends Promise {
+ set: () => UpdateChain;
+ where: () => UpdateChain;
+ returning: () => Promise;
+}
+
+/** Chainable update builder. Resolves `returning()` to `returning` array. Supports `.catch()` for fire-and-forget patterns. */
+export function makeUpdateChain(returning: unknown[] = []): UpdateChain {
+ const resolved = Promise.resolve(returning);
+ const c: UpdateChain = Object.assign(resolved, {
+ set: () => c,
+ where: () => c,
+ returning: () => resolved,
+ });
+ return c;
+}
+
+/** Chainable delete builder. Resolves `returning()` to `returning` array, or `where()` to void. */
+export function makeDeleteChain(returning?: unknown[]) {
+ if (returning !== undefined) {
+ const c = {
+ where: () => c,
+ returning: () => Promise.resolve(returning),
+ };
+ return c;
+ }
+ return { where: () => Promise.resolve() };
+}
diff --git a/app/src/__tests__/helpers/next-mocks.ts b/app/src/__tests__/helpers/next-mocks.ts
new file mode 100644
index 000000000..d0dfa7f43
--- /dev/null
+++ b/app/src/__tests__/helpers/next-mocks.ts
@@ -0,0 +1,30 @@
+/**
+ * Shared NextResponse mock factory for API route tests.
+ *
+ * Usage with vi.mock (hoisted):
+ * vi.mock("next/server", () => nextResponseMockFactory());
+ *
+ * Usage with vi.doMock (inside beforeEach):
+ * vi.doMock("next/server", () => nextResponseMockFactory());
+ */
+export function nextResponseMockFactory() {
+ return {
+ NextResponse: {
+ json: (body: unknown, init?: ResponseInit) => {
+ const headerEntries =
+ init?.headers && typeof init.headers === "object"
+ ? Object.entries(init.headers as Record)
+ : [];
+ const headerMap = new Map(headerEntries);
+ return {
+ status: init?.status ?? 200,
+ headers: {
+ get: (k: string) => headerMap.get(k) ?? null,
+ },
+ json: async () => body,
+ _body: body,
+ };
+ },
+ },
+ };
+}
diff --git a/app/src/__tests__/helpers/request-helpers.ts b/app/src/__tests__/helpers/request-helpers.ts
new file mode 100644
index 000000000..4141c58f8
--- /dev/null
+++ b/app/src/__tests__/helpers/request-helpers.ts
@@ -0,0 +1,33 @@
+/**
+ * Shared request/params builders for API route tests.
+ */
+
+/**
+ * Create a minimal Request stub that returns `body` from `.json()` and
+ * exposes a `headers.get()` shim. Routes can call `request.headers.get(...)`
+ * and receive `null` for absent headers without blowing up the mock.
+ */
+export function makeRequest(
+ body: unknown,
+ urlOrOptions?: string | { url?: string; headers?: Record },
+) {
+ const options =
+ typeof urlOrOptions === "string"
+ ? { url: urlOrOptions }
+ : (urlOrOptions ?? {});
+ const headerMap = new Map(
+ Object.entries(options.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]),
+ );
+ return {
+ json: async () => body,
+ ...(options.url ? { url: options.url } : {}),
+ headers: {
+ get: (name: string) => headerMap.get(name.toLowerCase()) ?? null,
+ },
+ } as Request;
+}
+
+/** Create a route params object for Next.js dynamic routes. */
+export function makeParams(id: string) {
+ return { params: Promise.resolve({ id }) };
+}
diff --git a/app/src/__tests__/instrumentation.test.ts b/app/src/__tests__/instrumentation.test.ts
new file mode 100644
index 000000000..1dd3b778e
--- /dev/null
+++ b/app/src/__tests__/instrumentation.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockBootstrapAdmin =
+ vi.fn<(opts: { email: string; password: string }) => Promise>();
+
+vi.mock("@/lib/auth/bootstrap", () => ({
+ bootstrapAdmin: mockBootstrapAdmin,
+}));
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("register (instrumentation hook)", () => {
+ let register: () => Promise;
+
+ const savedRuntime = process.env.NEXT_RUNTIME;
+ const savedEmail = process.env.BOOTSTRAP_ADMIN_EMAIL;
+ const savedPassword = process.env.BOOTSTRAP_ADMIN_PASSWORD;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/bootstrap", () => ({
+ bootstrapAdmin: mockBootstrapAdmin,
+ }));
+ const mod = await import("../instrumentation");
+ register = mod.register;
+ });
+
+ afterEach(() => {
+ // Restore env vars
+ if (savedRuntime === undefined) delete process.env.NEXT_RUNTIME;
+ else process.env.NEXT_RUNTIME = savedRuntime;
+
+ if (savedEmail === undefined) delete process.env.BOOTSTRAP_ADMIN_EMAIL;
+ else process.env.BOOTSTRAP_ADMIN_EMAIL = savedEmail;
+
+ if (savedPassword === undefined)
+ delete process.env.BOOTSTRAP_ADMIN_PASSWORD;
+ else process.env.BOOTSTRAP_ADMIN_PASSWORD = savedPassword;
+ });
+
+ it("skips bootstrap when NEXT_RUNTIME is not nodejs", async () => {
+ process.env.NEXT_RUNTIME = "edge";
+ process.env.BOOTSTRAP_ADMIN_EMAIL = "admin@example.com";
+ process.env.BOOTSTRAP_ADMIN_PASSWORD = "password123";
+ await register();
+ expect(mockBootstrapAdmin).not.toHaveBeenCalled();
+ });
+
+ it("skips bootstrap when BOOTSTRAP_ADMIN_EMAIL is not set", async () => {
+ process.env.NEXT_RUNTIME = "nodejs";
+ delete process.env.BOOTSTRAP_ADMIN_EMAIL;
+ process.env.BOOTSTRAP_ADMIN_PASSWORD = "password123";
+ await register();
+ expect(mockBootstrapAdmin).not.toHaveBeenCalled();
+ });
+
+ it("skips bootstrap when BOOTSTRAP_ADMIN_PASSWORD is not set", async () => {
+ process.env.NEXT_RUNTIME = "nodejs";
+ process.env.BOOTSTRAP_ADMIN_EMAIL = "admin@example.com";
+ delete process.env.BOOTSTRAP_ADMIN_PASSWORD;
+ await register();
+ expect(mockBootstrapAdmin).not.toHaveBeenCalled();
+ });
+
+ it("calls bootstrapAdmin with env credentials on nodejs runtime", async () => {
+ process.env.NEXT_RUNTIME = "nodejs";
+ process.env.BOOTSTRAP_ADMIN_EMAIL = "admin@example.com";
+ process.env.BOOTSTRAP_ADMIN_PASSWORD = "password123";
+ mockBootstrapAdmin.mockResolvedValue(undefined);
+ await register();
+ expect(mockBootstrapAdmin).toHaveBeenCalledWith({
+ email: "admin@example.com",
+ password: "password123",
+ });
+ });
+
+ it("swallows errors from bootstrapAdmin without crashing", async () => {
+ process.env.NEXT_RUNTIME = "nodejs";
+ process.env.BOOTSTRAP_ADMIN_EMAIL = "admin@example.com";
+ process.env.BOOTSTRAP_ADMIN_PASSWORD = "password123";
+ mockBootstrapAdmin.mockRejectedValue(new Error("DB connection failed"));
+ await expect(register()).resolves.toBeUndefined();
+ });
+
+ it("swallows errors from query middleware bootstrap without crashing", async () => {
+ vi.resetModules();
+ vi.doMock("@/lib/auth/bootstrap", () => ({
+ bootstrapAdmin: mockBootstrapAdmin,
+ }));
+ vi.doMock("@/lib/query/middleware/bootstrap", () => ({
+ bootstrapQueryMiddleware: () => {
+ throw new Error("middleware registration failed");
+ },
+ }));
+ const mod = await import("../instrumentation");
+ process.env.NEXT_RUNTIME = "nodejs";
+ delete process.env.BOOTSTRAP_ADMIN_EMAIL;
+ delete process.env.BOOTSTRAP_ADMIN_PASSWORD;
+ await expect(mod.register()).resolves.toBeUndefined();
+ });
+});
diff --git a/app/src/__tests__/proxy.test.ts b/app/src/__tests__/proxy.test.ts
new file mode 100644
index 000000000..05eb1cf98
--- /dev/null
+++ b/app/src/__tests__/proxy.test.ts
@@ -0,0 +1,196 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { NextRequest } from "next/server";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockGetToken = vi.fn();
+
+vi.mock("next-auth/jwt", () => ({
+ getToken: (...args: unknown[]) => mockGetToken(...args),
+}));
+
+// Import after mocks
+const { proxy, config } = await import("../proxy");
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeRequest(
+ pathname: string,
+ options?: { headers?: Record },
+): NextRequest {
+ const url = new URL(pathname, "http://localhost:3000");
+ return {
+ nextUrl: url,
+ headers: new Headers(options?.headers ?? {}),
+ } as unknown as NextRequest;
+}
+
+function matchesRoute(pathname: string): boolean {
+ const pattern = config.matcher[0];
+ const regex = new RegExp(`^${pattern}$`);
+ return regex.test(pathname);
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("proxy", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetToken.mockResolvedValue(null);
+ });
+
+ describe("matcher config", () => {
+ it("excludes static assets", () => {
+ expect(matchesRoute("/_next/static/chunk.js")).toBe(false);
+ expect(matchesRoute("/_next/image/foo")).toBe(false);
+ expect(matchesRoute("/favicon.ico")).toBe(false);
+ });
+
+ it("includes page routes", () => {
+ expect(matchesRoute("/")).toBe(true);
+ expect(matchesRoute("/connections")).toBe(true);
+ expect(matchesRoute("/login")).toBe(true);
+ });
+
+ it("includes API routes", () => {
+ expect(matchesRoute("/api/dashboards")).toBe(true);
+ expect(matchesRoute("/api/query")).toBe(true);
+ });
+ });
+
+ describe("public routes", () => {
+ it("passes through /login", async () => {
+ const res = await proxy(makeRequest("/login"));
+ expect(res.status).toBe(200);
+ });
+
+ it("passes through /signup", async () => {
+ const res = await proxy(makeRequest("/signup"));
+ expect(res.status).toBe(200);
+ });
+
+ it("passes through /change-password", async () => {
+ const res = await proxy(makeRequest("/change-password"));
+ expect(res.status).toBe(200);
+ });
+
+ it("passes through /api/auth/* routes", async () => {
+ const res = await proxy(makeRequest("/api/auth/session"));
+ expect(res.status).toBe(200);
+ });
+
+ it("passes through /api/auth/bootstrap-status", async () => {
+ const res = await proxy(makeRequest("/api/auth/bootstrap-status"));
+ expect(res.status).toBe(200);
+ });
+
+ it("passes through /api/docs", async () => {
+ const res = await proxy(makeRequest("/api/docs"));
+ expect(res.status).toBe(200);
+ });
+
+ it("passes through /api/openapi", async () => {
+ const res = await proxy(makeRequest("/api/openapi"));
+ expect(res.status).toBe(200);
+ });
+
+ it("passes through /api/openapi.json", async () => {
+ const res = await proxy(makeRequest("/api/openapi.json"));
+ expect(res.status).toBe(200);
+ });
+ });
+
+ describe("unauthenticated requests", () => {
+ it("redirects page requests to /login", async () => {
+ const res = await proxy(makeRequest("/connections"));
+ expect(res.status).toBe(307);
+ const location = res.headers.get("location") ?? "";
+ expect(location).toContain("/login");
+ expect(location).toContain("callbackUrl=%2Fconnections");
+ });
+
+ it("returns 401 JSON for API requests", async () => {
+ const res = await proxy(makeRequest("/api/dashboards"));
+ expect(res.status).toBe(401);
+ expect(res.headers.get("content-type")).toContain("application/json");
+ });
+ });
+
+ describe("API key passthrough", () => {
+ it("passes through nb_ Bearer tokens on API routes", async () => {
+ const res = await proxy(
+ makeRequest("/api/dashboards", {
+ headers: { authorization: "Bearer nb_test_key_abc123" },
+ }),
+ );
+ expect(res.status).toBe(200);
+ });
+
+ it("does not pass through non-nb_ tokens", async () => {
+ const res = await proxy(
+ makeRequest("/api/dashboards", {
+ headers: { authorization: "Bearer some_other_token" },
+ }),
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("does not pass through nb_ tokens on page routes", async () => {
+ const res = await proxy(
+ makeRequest("/connections", {
+ headers: { authorization: "Bearer nb_test_key" },
+ }),
+ );
+ expect(res.status).toBe(307);
+ });
+ });
+
+ describe("authenticated requests", () => {
+ it("passes through authenticated page requests", async () => {
+ mockGetToken.mockResolvedValue({ sub: "user-1" });
+ const res = await proxy(makeRequest("/"));
+ expect(res.status).toBe(200);
+ });
+
+ it("passes through authenticated API requests", async () => {
+ mockGetToken.mockResolvedValue({ sub: "user-1" });
+ const res = await proxy(makeRequest("/api/dashboards"));
+ expect(res.status).toBe(200);
+ });
+
+ it("redirects to /change-password when forcePasswordChange is true", async () => {
+ mockGetToken.mockResolvedValue({
+ sub: "user-1",
+ forcePasswordChange: true,
+ });
+ const res = await proxy(makeRequest("/connections"));
+ expect(res.status).toBe(307);
+ expect(res.headers.get("location")).toContain("/change-password");
+ });
+
+ it("does not redirect to /change-password for API routes", async () => {
+ mockGetToken.mockResolvedValue({
+ sub: "user-1",
+ forcePasswordChange: true,
+ });
+ const res = await proxy(makeRequest("/api/dashboards"));
+ expect(res.status).toBe(200);
+ });
+
+ it("does not redirect when already on /change-password", async () => {
+ mockGetToken.mockResolvedValue({
+ sub: "user-1",
+ forcePasswordChange: true,
+ });
+ const res = await proxy(makeRequest("/change-password"));
+ // /change-password is a public route, so it passes through before token check
+ expect(res.status).toBe(200);
+ });
+ });
+});
diff --git a/app/src/__tests__/security-headers.test.ts b/app/src/__tests__/security-headers.test.ts
new file mode 100644
index 000000000..d228b7124
--- /dev/null
+++ b/app/src/__tests__/security-headers.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect } from "vitest";
+
+/**
+ * Verify that next.config.ts exports the expected security response headers.
+ *
+ * We dynamically import the config (ESM default export) and call its
+ * `headers()` function, then assert on the returned header list.
+ */
+describe("security response headers", () => {
+ it("exports a headers function that returns security headers for all routes", async () => {
+ // next.config.ts uses import.meta.dirname — Vitest handles this natively.
+ const mod = await import("../../next.config");
+ const nextConfig = mod.default;
+
+ expect(nextConfig.headers).toBeDefined();
+ expect(typeof nextConfig.headers).toBe("function");
+
+ const result = await nextConfig.headers!();
+
+ // Should have exactly one entry covering all routes
+ expect(result).toHaveLength(1);
+ expect(result[0].source).toBe("/:path*");
+
+ const headers = result[0].headers;
+
+ // Build a lookup for easier assertions
+ const headerMap = Object.fromEntries(
+ headers.map((h: { key: string; value: string }) => [h.key, h.value]),
+ );
+
+ expect(headerMap["X-Frame-Options"]).toBe("DENY");
+ expect(headerMap["X-Content-Type-Options"]).toBe("nosniff");
+ expect(headerMap["Referrer-Policy"]).toBe(
+ "strict-origin-when-cross-origin",
+ );
+ expect(headerMap["Permissions-Policy"]).toBe(
+ "camera=(), microphone=(), geolocation=()",
+ );
+ expect(headerMap["Strict-Transport-Security"]).toBe(
+ "max-age=31536000; includeSubDomains",
+ );
+ });
+
+ it("does NOT include Content-Security-Policy (deferred — needs ECharts/Leaflet/NVL tuning)", async () => {
+ const mod = await import("../../next.config");
+ const nextConfig = mod.default;
+ const result = await nextConfig.headers!();
+ const headers = result[0].headers;
+
+ const cspHeader = headers.find(
+ (h: { key: string }) => h.key === "Content-Security-Policy",
+ );
+ expect(cspHeader).toBeUndefined();
+ });
+});
diff --git a/app/src/app/(auth)/change-password/page.tsx b/app/src/app/(auth)/change-password/page.tsx
new file mode 100644
index 000000000..8d67fe37e
--- /dev/null
+++ b/app/src/app/(auth)/change-password/page.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useState } from "react";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Input,
+ Label,
+ Alert,
+ AlertDescription,
+} from "@neoboard/components";
+import { LoadingButton, PasswordInput } from "@neoboard/components";
+
+export default function ChangePasswordPage() {
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setLoading(true);
+ setError("");
+
+ const formData = new FormData(e.currentTarget);
+ const currentPassword = formData.get("currentPassword") as string;
+ const newPassword = formData.get("newPassword") as string;
+ const confirmPassword = formData.get("confirmPassword") as string;
+
+ if (newPassword !== confirmPassword) {
+ setError("New passwords do not match");
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const res = await fetch("/api/users/me/password", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ currentPassword, newPassword }),
+ });
+
+ if (!res.ok) {
+ const body = await res.json();
+ setError(body.error ?? "Failed to change password");
+ setLoading(false);
+ return;
+ }
+
+ // Password changed — redirect to dashboard (full reload to refresh JWT)
+ window.location.href = "/";
+ } catch {
+ setError("Something went wrong. Please try again.");
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ Change Password
+
+ You must change your password before continuing.
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/app/(auth)/login/__tests__/page.test.tsx b/app/src/app/(auth)/login/__tests__/page.test.tsx
new file mode 100644
index 000000000..d65d977be
--- /dev/null
+++ b/app/src/app/(auth)/login/__tests__/page.test.tsx
@@ -0,0 +1,228 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import React from "react";
+
+/* ---------- mocks ---------- */
+
+const mockPush = vi.fn();
+const mockSignIn = vi.fn();
+
+vi.mock("next-auth/react", () => ({
+ signIn: (...args: unknown[]) => mockSignIn(...args),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: mockPush }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+vi.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ href,
+ children,
+ ...rest
+ }: {
+ href: string;
+ children: React.ReactNode;
+ }) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock("@neoboard/components", () => ({
+ Card: ({
+ children,
+ className,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ }) => {children}
,
+ CardContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardFooter: ({
+ children,
+ className,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ }) => {children}
,
+ CardHeader: ({
+ children,
+ className,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ }) => {children}
,
+ CardTitle: ({
+ children,
+ className,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ }) => {children} ,
+ Input: (props: React.InputHTMLAttributes) => (
+
+ ),
+ Label: ({
+ children,
+ htmlFor,
+ }: {
+ children: React.ReactNode;
+ htmlFor?: string;
+ }) => {children} ,
+ Alert: ({ children }: { children: React.ReactNode; variant?: string }) => (
+ {children}
+ ),
+ AlertDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ LoadingButton: ({
+ children,
+ loading,
+ loadingText,
+ ...rest
+ }: React.ButtonHTMLAttributes & {
+ loading?: boolean;
+ loadingText?: string;
+ }) => (
+
+ {loading ? loadingText : children}
+
+ ),
+ PasswordInput: (props: React.InputHTMLAttributes) => (
+
+ ),
+}));
+
+/* ---------- import under test ---------- */
+import LoginPage from "../page";
+
+/* ---------- helpers ---------- */
+
+function mockFetchBootstrapStatus(registrationEnabled: boolean) {
+ global.fetch = vi.fn().mockResolvedValue({
+ json: () =>
+ Promise.resolve({
+ data: { bootstrapRequired: false, registrationEnabled },
+ }),
+ });
+}
+
+/* ---------- tests ---------- */
+
+describe("LoginPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("shows the signup link when registration is enabled", async () => {
+ mockFetchBootstrapStatus(true);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Sign up")).toBeDefined();
+ });
+
+ const signupLink = screen.getByText("Sign up");
+ expect(signupLink.closest("a")).toHaveAttribute("href", "/signup");
+ });
+
+ it("hides the signup link when registration is disabled", async () => {
+ mockFetchBootstrapStatus(false);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.queryByText("Sign up")).toBeNull();
+ });
+ });
+
+ it("shows the signup link by default before fetch completes", () => {
+ // Fetch never resolves — default state should show the link
+ global.fetch = vi.fn().mockReturnValue(new Promise(() => {}));
+
+ render( );
+
+ expect(screen.getByText("Sign up")).toBeDefined();
+ });
+
+ it("keeps the signup link when fetch fails", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
+
+ render( );
+
+ // Default state is registrationEnabled=true, fetch error doesn't change it
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalled();
+ });
+
+ expect(screen.getByText("Sign up")).toBeDefined();
+ });
+
+ it("renders the login form with email and password fields", () => {
+ mockFetchBootstrapStatus(true);
+
+ render( );
+
+ expect(screen.getByLabelText("Email")).toBeDefined();
+ expect(screen.getByLabelText("Password")).toBeDefined();
+ expect(screen.getByText("Sign in")).toBeDefined();
+ });
+
+ it("renders the NeoBoard title", () => {
+ mockFetchBootstrapStatus(true);
+
+ render( );
+
+ expect(screen.getByText("NeoBoard")).toBeDefined();
+ });
+
+ it("shows error message when login fails", async () => {
+ mockFetchBootstrapStatus(true);
+ mockSignIn.mockResolvedValue({ error: "CredentialsSignin" });
+
+ const user = userEvent.setup();
+ render( );
+
+ const emailInput = screen.getByLabelText("Email");
+ const passwordInput = screen.getByLabelText("Password");
+ const submitButton = screen.getByText("Sign in");
+
+ await user.type(emailInput, "test@example.com");
+ await user.type(passwordInput, "wrongpassword");
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText("Invalid email or password")).toBeDefined();
+ });
+ });
+
+ it("redirects to callbackUrl on successful login", async () => {
+ mockFetchBootstrapStatus(true);
+ mockSignIn.mockResolvedValue({ error: null });
+
+ const user = userEvent.setup();
+ render( );
+
+ const emailInput = screen.getByLabelText("Email");
+ const passwordInput = screen.getByLabelText("Password");
+ const submitButton = screen.getByText("Sign in");
+
+ await user.type(emailInput, "test@example.com");
+ await user.type(passwordInput, "correctpassword");
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith("/");
+ });
+ });
+});
diff --git a/app/src/app/(auth)/login/page.tsx b/app/src/app/(auth)/login/page.tsx
new file mode 100644
index 000000000..9c3c8ecbd
--- /dev/null
+++ b/app/src/app/(auth)/login/page.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { Suspense, useState, useEffect } from "react";
+import { signIn } from "next-auth/react";
+import { useRouter, useSearchParams } from "next/navigation";
+import Link from "next/link";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+ Input,
+ Label,
+ Alert,
+ AlertDescription,
+} from "@neoboard/components";
+import { LoadingButton, PasswordInput } from "@neoboard/components";
+
+function LoginForm() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const callbackUrl = searchParams.get("callbackUrl") ?? "/";
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setLoading(true);
+ setError("");
+
+ const formData = new FormData(e.currentTarget);
+ try {
+ const result = await signIn("credentials", {
+ email: formData.get("email"),
+ password: formData.get("password"),
+ redirect: false,
+ });
+
+ if (result?.error) {
+ setError("Invalid email or password");
+ setLoading(false);
+ } else if (result) {
+ router.push(callbackUrl);
+ } else {
+ // Nothing returned → server unreachable
+ setError("Unable to sign in. Please try again.");
+ setLoading(false);
+ }
+ } catch {
+ // Network error or server unreachable
+ setError("Unable to reach server. Please check your connection.");
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+export default function LoginPage() {
+ const [registrationEnabled, setRegistrationEnabled] = useState(true);
+
+ useEffect(() => {
+ fetch("/api/auth/bootstrap-status")
+ .then((r) => r.json())
+ .then((body) => {
+ const payload = body?.data ?? body;
+ setRegistrationEnabled(payload?.registrationEnabled !== false);
+ })
+ .catch(() => {});
+ }, []);
+
+ return (
+
+
+
+ NeoBoard
+
+ Visual dashboards for Neo4j & PostgreSQL
+
+ Sign in to your account
+
+
+
+
+
+
+ {registrationEnabled && (
+
+
+ Don't have an account?{" "}
+
+ Sign up
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/src/app/(auth)/signup/__tests__/page.test.tsx b/app/src/app/(auth)/signup/__tests__/page.test.tsx
new file mode 100644
index 000000000..ff372b2d3
--- /dev/null
+++ b/app/src/app/(auth)/signup/__tests__/page.test.tsx
@@ -0,0 +1,339 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import React from "react";
+
+/* ---------- mocks ---------- */
+
+const mockPush = vi.fn();
+const mockSignIn = vi.fn();
+const mockSignup = vi.fn();
+
+vi.mock("next-auth/react", () => ({
+ signIn: (...args: unknown[]) => mockSignIn(...args),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: mockPush }),
+}));
+
+vi.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ href,
+ children,
+ ...rest
+ }: {
+ href: string;
+ children: React.ReactNode;
+ }) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock("@/lib/auth/signup", () => ({
+ signup: (...args: unknown[]) => mockSignup(...args),
+}));
+
+vi.mock("@neoboard/components", () => ({
+ Card: ({
+ children,
+ className,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ }) => {children}
,
+ CardContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardFooter: ({
+ children,
+ className,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ }) => {children}
,
+ CardHeader: ({
+ children,
+ className,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ }) => {children}
,
+ CardTitle: ({
+ children,
+ className,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ }) => {children} ,
+ Input: (props: React.InputHTMLAttributes) => (
+
+ ),
+ Label: ({
+ children,
+ htmlFor,
+ }: {
+ children: React.ReactNode;
+ htmlFor?: string;
+ }) => {children} ,
+ Alert: ({
+ children,
+ }: {
+ children: React.ReactNode;
+ variant?: string;
+ className?: string;
+ }) => {children}
,
+ AlertDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ LoadingButton: ({
+ children,
+ loading,
+ loadingText,
+ ...rest
+ }: React.ButtonHTMLAttributes & {
+ loading?: boolean;
+ loadingText?: string;
+ }) => (
+
+ {loading ? loadingText : children}
+
+ ),
+ PasswordInput: (props: React.InputHTMLAttributes) => (
+
+ ),
+}));
+
+/* ---------- import under test ---------- */
+import SignupPage from "../page";
+
+/* ---------- helpers ---------- */
+
+function mockFetchBootstrapStatus(
+ bootstrapRequired: boolean,
+ registrationEnabled: boolean,
+) {
+ global.fetch = vi.fn().mockResolvedValue({
+ json: () =>
+ Promise.resolve({
+ data: { bootstrapRequired, registrationEnabled },
+ }),
+ });
+}
+
+/* ---------- tests ---------- */
+
+describe("SignupPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ----- Registration disabled -----
+
+ it("shows 'Registration Disabled' when registration is disabled and bootstrap is not required", async () => {
+ mockFetchBootstrapStatus(false, false);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Registration Disabled")).toBeDefined();
+ });
+
+ expect(
+ screen.getByText(
+ "Self-registration is disabled. Contact your administrator for an account.",
+ ),
+ ).toBeDefined();
+ expect(screen.getByText("Back to sign in")).toBeDefined();
+ expect(screen.getByText("Back to sign in").closest("a")).toHaveAttribute(
+ "href",
+ "/login",
+ );
+ });
+
+ it("does not show the signup form when registration is disabled", async () => {
+ mockFetchBootstrapStatus(false, false);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Registration Disabled")).toBeDefined();
+ });
+
+ // Form fields should not be present
+ expect(screen.queryByLabelText("Name")).toBeNull();
+ expect(screen.queryByLabelText("Email")).toBeNull();
+ });
+
+ // ----- Bootstrap mode (first admin setup) -----
+
+ it("shows bootstrap form even when registration is disabled (bootstrapRequired overrides)", async () => {
+ mockFetchBootstrapStatus(true, false);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("First Admin Setup")).toBeDefined();
+ });
+
+ expect(screen.getByText(/No users exist yet/)).toBeDefined();
+ expect(screen.getByLabelText("Bootstrap Token")).toBeDefined();
+ expect(screen.getByText("Create Admin Account")).toBeDefined();
+ });
+
+ it("shows bootstrap token field when bootstrapRequired is true", async () => {
+ mockFetchBootstrapStatus(true, true);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("First Admin Setup")).toBeDefined();
+ });
+
+ expect(screen.getByLabelText("Bootstrap Token")).toBeDefined();
+ });
+
+ // ----- Normal registration -----
+
+ it("shows normal signup form when registration is enabled and bootstrap is not required", async () => {
+ mockFetchBootstrapStatus(false, true);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Create your account")).toBeDefined();
+ });
+
+ expect(screen.getByLabelText("Name")).toBeDefined();
+ expect(screen.getByLabelText("Email")).toBeDefined();
+ expect(screen.getByLabelText("Password")).toBeDefined();
+ expect(screen.getByLabelText("Confirm Password")).toBeDefined();
+ expect(screen.queryByLabelText("Bootstrap Token")).toBeNull();
+ expect(screen.getByText("Create account")).toBeDefined();
+ });
+
+ it("shows 'Already have an account?' link in normal mode", async () => {
+ mockFetchBootstrapStatus(false, true);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Sign in")).toBeDefined();
+ });
+
+ expect(screen.getByText("Sign in").closest("a")).toHaveAttribute(
+ "href",
+ "/login",
+ );
+ });
+
+ // ----- Form validation -----
+
+ it("shows error when passwords do not match", async () => {
+ mockFetchBootstrapStatus(false, true);
+
+ const user = userEvent.setup();
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toBeDefined();
+ });
+
+ await user.type(screen.getByLabelText("Name"), "Test User");
+ await user.type(screen.getByLabelText("Email"), "test@example.com");
+ await user.type(screen.getByLabelText("Password"), "password123");
+ await user.type(screen.getByLabelText("Confirm Password"), "different456");
+ await user.click(screen.getByText("Create account"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Passwords do not match")).toBeDefined();
+ });
+ });
+
+ it("shows error from signup server action", async () => {
+ mockFetchBootstrapStatus(false, true);
+ mockSignup.mockResolvedValue({
+ success: false,
+ error: "Email already registered",
+ });
+
+ const user = userEvent.setup();
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toBeDefined();
+ });
+
+ await user.type(screen.getByLabelText("Name"), "Test User");
+ await user.type(screen.getByLabelText("Email"), "test@example.com");
+ await user.type(screen.getByLabelText("Password"), "password123");
+ await user.type(screen.getByLabelText("Confirm Password"), "password123");
+ await user.click(screen.getByText("Create account"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Email already registered")).toBeDefined();
+ });
+ });
+
+ it("redirects to / after successful signup and auto-login", async () => {
+ mockFetchBootstrapStatus(false, true);
+ mockSignup.mockResolvedValue({ success: true });
+ mockSignIn.mockResolvedValue({ error: null });
+
+ const user = userEvent.setup();
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toBeDefined();
+ });
+
+ await user.type(screen.getByLabelText("Name"), "Test User");
+ await user.type(screen.getByLabelText("Email"), "test@example.com");
+ await user.type(screen.getByLabelText("Password"), "password123");
+ await user.type(screen.getByLabelText("Confirm Password"), "password123");
+ await user.click(screen.getByText("Create account"));
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith("/");
+ });
+ });
+
+ it("redirects to /login when auto-login fails after signup", async () => {
+ mockFetchBootstrapStatus(false, true);
+ mockSignup.mockResolvedValue({ success: true });
+ mockSignIn.mockResolvedValue({ error: "some-error" });
+
+ const user = userEvent.setup();
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toBeDefined();
+ });
+
+ await user.type(screen.getByLabelText("Name"), "Test User");
+ await user.type(screen.getByLabelText("Email"), "test@example.com");
+ await user.type(screen.getByLabelText("Password"), "password123");
+ await user.type(screen.getByLabelText("Confirm Password"), "password123");
+ await user.click(screen.getByText("Create account"));
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith("/login");
+ });
+ });
+
+ // ----- Default state -----
+
+ it("renders NeoBoard title", () => {
+ global.fetch = vi.fn().mockReturnValue(new Promise(() => {}));
+
+ render( );
+
+ expect(screen.getByText("NeoBoard")).toBeDefined();
+ });
+});
diff --git a/app/src/app/(auth)/signup/page.tsx b/app/src/app/(auth)/signup/page.tsx
new file mode 100644
index 000000000..65d3ecf76
--- /dev/null
+++ b/app/src/app/(auth)/signup/page.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { signIn } from "next-auth/react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { signup } from "@/lib/auth/signup";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+ Input,
+ Label,
+ Alert,
+ AlertDescription,
+} from "@neoboard/components";
+import { LoadingButton, PasswordInput } from "@neoboard/components";
+
+export default function SignupPage() {
+ const router = useRouter();
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [bootstrapRequired, setBootstrapRequired] = useState(false);
+ const [registrationEnabled, setRegistrationEnabled] = useState(true);
+
+ useEffect(() => {
+ fetch("/api/auth/bootstrap-status")
+ .then((r) => r.json())
+ .then((body) => {
+ // Supports envelope format: { data: { bootstrapRequired }, ... }
+ const payload = body?.data ?? body;
+ setBootstrapRequired(payload?.bootstrapRequired === true);
+ setRegistrationEnabled(payload?.registrationEnabled !== false);
+ })
+ .catch(() => {});
+ }, []);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setLoading(true);
+ setError("");
+
+ const formData = new FormData(e.currentTarget);
+ const password = formData.get("password") as string;
+ const confirmPassword = formData.get("confirmPassword") as string;
+
+ if (password !== confirmPassword) {
+ setError("Passwords do not match");
+ setLoading(false);
+ return;
+ }
+
+ const result = await signup(formData);
+
+ if (!result.success) {
+ setError(result.error);
+ setLoading(false);
+ return;
+ }
+
+ // Auto-login after signup
+ const signInResult = await signIn("credentials", {
+ email: formData.get("email"),
+ password,
+ redirect: false,
+ });
+
+ if (signInResult?.error) {
+ router.push("/login");
+ } else {
+ router.push("/");
+ }
+ }
+
+ if (!registrationEnabled && !bootstrapRequired) {
+ return (
+
+
+
+ NeoBoard
+ Registration Disabled
+
+
+
+
+ Self-registration is disabled. Contact your administrator for an
+ account.
+
+
+
+
+
+
+ Back to sign in
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ NeoBoard
+
+ Visual dashboards for Neo4j & PostgreSQL
+
+
+ {bootstrapRequired ? "First Admin Setup" : "Create your account"}
+
+
+
+ {bootstrapRequired && (
+
+
+ No users exist yet. You are setting up the first admin account.
+ Enter the bootstrap token from your .env file.
+
+
+ )}
+
+
+
+
+ Already have an account?{" "}
+
+ Sign in
+
+
+
+
+
+ );
+}
diff --git a/app/src/app/(dashboard)/[id]/edit/page.tsx b/app/src/app/(dashboard)/[id]/edit/page.tsx
new file mode 100644
index 000000000..74f61ec2b
--- /dev/null
+++ b/app/src/app/(dashboard)/[id]/edit/page.tsx
@@ -0,0 +1,656 @@
+"use client";
+
+import { use, useEffect, useCallback, useRef, useState, useMemo } from "react";
+import { useRouter } from "next/navigation";
+import { useSession } from "next-auth/react";
+import {
+ ArrowLeft,
+ Filter,
+ Plus,
+ Save,
+ LayoutDashboard,
+ Users,
+} from "lucide-react";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ useDashboard,
+ useUpdateDashboard,
+ useUpdateDashboardThumbnails,
+} from "@/hooks/use-dashboards";
+import { useConnections } from "@/hooks/use-connections";
+import { useUnsavedChangesWarning } from "@/hooks/use-unsaved-changes-warning";
+import { useParameterStore } from "@/stores/parameter-store";
+import { filterParentParams } from "@/lib/parameter/format-parameter-value";
+import { buildParameterSourceMap } from "@/lib/parameter/collect-parameter-names";
+import { scrollToWidgetWhenReady } from "@/lib/widget/scroll-to-widget";
+import { useDashboardStore } from "@/stores/dashboard-store";
+import { useWidgetTemplates } from "@/hooks/use-widget-templates";
+import { DashboardContainer } from "@/components/dashboard-container";
+import { PageTabs } from "@/components/page-tabs";
+import { WidgetEditorModal } from "@/components/widget-editor-modal";
+import { DashboardAssignPanel } from "@/components/dashboard-assign-panel";
+import { SaveTemplateDialog } from "@/components/save-template-dialog";
+import type { ConnectorType } from "@/lib/connector/connector-types";
+import { migrateLayout } from "@/lib/dashboard/migrate-layout";
+import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
+import type {
+ DashboardWidget,
+ GridLayoutItem,
+ WidgetTemplate,
+} from "@/lib/db/schema";
+import { captureDashboardThumbnails } from "@/lib/dashboard/capture-dashboard-thumbnails";
+import {
+ Button,
+ Skeleton,
+ Alert,
+ AlertDescription,
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@neoboard/components";
+import {
+ ConfirmDialog,
+ EmptyState,
+ LoadingButton,
+ Toolbar,
+ ToolbarSection,
+ ToolbarSeparator,
+} from "@neoboard/components";
+
+export default function DashboardEditorPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ id: string }>;
+ searchParams: Promise<{ page?: string; templateId?: string }>;
+}) {
+ const { id } = use(params);
+ const { page: pageParam, templateId: templateIdParam } = use(searchParams);
+ const router = useRouter();
+ const saveToDashboard = useParameterStore((s) => s.saveToDashboard);
+ const restoreFromDashboard = useParameterStore((s) => s.restoreFromDashboard);
+ const prevDashboardId = useRef(null);
+
+ useEffect(() => {
+ if (prevDashboardId.current && prevDashboardId.current !== id) {
+ saveToDashboard(prevDashboardId.current);
+ }
+ prevDashboardId.current = id;
+ restoreFromDashboard(id);
+ return () => {
+ saveToDashboard(id);
+ };
+ }, [id, saveToDashboard, restoreFromDashboard]);
+
+ const parameters = useParameterStore((s) => s.parameters);
+ const parameterCount = useMemo(
+ () => filterParentParams(Object.entries(parameters)).length,
+ [parameters],
+ );
+ const hasParameters = parameterCount > 0;
+ // null = auto mode (show when params exist), boolean = user override
+ const [barOverride, setBarOverride] = useState(null);
+ const effectiveShowBar = barOverride !== null ? barOverride : hasParameters;
+
+ const initialPage = pageParam !== undefined ? parseInt(pageParam, 10) : 0;
+ const [visitedPages, setVisitedPages] = useState>(
+ () => new Set([isNaN(initialPage) ? 0 : initialPage]),
+ );
+
+ function markVisited(index: number) {
+ setVisitedPages((prev) => {
+ if (prev.has(index)) return prev;
+ const next = new Set(prev);
+ next.add(index);
+ return next;
+ });
+ }
+
+ function handleSelectPage(index: number) {
+ markVisited(index);
+ setActivePage(index);
+ }
+
+ // After reorderPages the store adjusts activePageIndex. Mark the new
+ // index as visited so the page stays in the DOM when switching away.
+ function handleReorderPages(fromIndex: number, toIndex: number) {
+ reorderPages(fromIndex, toIndex);
+ const newActive = useDashboardStore.getState().activePageIndex;
+ markVisited(newActive);
+ }
+
+ const queryClient = useQueryClient();
+ const { data: session } = useSession();
+ const systemRole = session?.user?.role ?? "creator";
+ const isAdmin = systemRole === "admin";
+
+ const { data: dashboard, isLoading } = useDashboard(id);
+ const { data: connections } = useConnections();
+ const updateDashboard = useUpdateDashboard();
+ const updateThumbnails = useUpdateDashboardThumbnails();
+ const gridContainerRef = useRef(null);
+ const layout = useDashboardStore((s) => s.layout);
+ const activePageIndex = useDashboardStore((s) => s.activePageIndex);
+ const setLayout = useDashboardStore((s) => s.setLayout);
+ const setActivePage = useDashboardStore((s) => s.setActivePage);
+ const addPage = useDashboardStore((s) => s.addPage);
+ const removePage = useDashboardStore((s) => s.removePage);
+ const renamePage = useDashboardStore((s) => s.renamePage);
+ const reorderPages = useDashboardStore((s) => s.reorderPages);
+ const addWidget = useDashboardStore((s) => s.addWidget);
+ const removeWidget = useDashboardStore((s) => s.removeWidget);
+ const updateWidget = useDashboardStore((s) => s.updateWidget);
+ const updateGridLayout = useDashboardStore((s) => s.updateGridLayout);
+ const duplicateWidget = useDashboardStore((s) => s.duplicateWidget);
+ const markSaved = useDashboardStore((s) => s.markSaved);
+
+ const {
+ showNavWarning,
+ setShowNavWarning,
+ confirmNavigation,
+ cancelNavigation,
+ requestNavigation,
+ } = useUnsavedChangesWarning();
+
+ const parameterSourceMap = useMemo(
+ () => buildParameterSourceMap(layout),
+ [layout],
+ );
+
+ const handleNavigateToPage = useCallback(
+ (pageId: string, scrollToWidgetId?: string) => {
+ const index = layout.pages.findIndex((p) => p.id === pageId);
+ if (index >= 0) {
+ markVisited(index);
+ setActivePage(index);
+ if (scrollToWidgetId) {
+ scrollToWidgetWhenReady(scrollToWidgetId);
+ }
+ }
+ },
+ [layout.pages, setActivePage],
+ );
+
+ // Template sync — fetch all tenant templates once; build lookup map
+ const { data: allTemplates } = useWidgetTemplates();
+ const templateMap = useMemo>(
+ () => Object.fromEntries((allTemplates ?? []).map((t) => [t.id, t])),
+ [allTemplates],
+ );
+
+ const handleSyncWidget = useCallback(
+ (widget: DashboardWidget) => {
+ const tmpl = widget.templateId
+ ? templateMap[widget.templateId]
+ : undefined;
+ if (!tmpl) return; // template deleted — "Detach" will clean up
+ updateWidget(widget.id, {
+ ...widget,
+ chartType: tmpl.chartType,
+ query: tmpl.query ?? "",
+ settings: {
+ ...widget.settings,
+ ...(tmpl.settings ?? undefined),
+ // Never overwrite the widget's connection
+ connectionId: widget.settings?.connectionId,
+ },
+ templateSyncedAt:
+ tmpl.updatedAt?.toISOString() ?? new Date().toISOString(),
+ });
+ },
+ [templateMap, updateWidget],
+ );
+
+ const handleDetachWidget = useCallback(
+ (widgetId: string) => {
+ const page = layout.pages.find((p) =>
+ p.widgets.some((w) => w.id === widgetId),
+ );
+ const widget = page?.widgets.find((w) => w.id === widgetId);
+ if (!widget) return;
+ updateWidget(widgetId, {
+ ...widget,
+ templateId: undefined,
+ templateSyncedAt: undefined,
+ });
+ },
+ [layout.pages, updateWidget],
+ );
+
+ const [editorOpen, setEditorOpen] = useState(!!templateIdParam);
+ const [editorMode, setEditorMode] = useState<"add" | "edit">("add");
+ const [editingWidget, setEditingWidget] = useState<
+ DashboardWidget | undefined
+ >();
+ const [saveError, setSaveError] = useState(null);
+ const [templateWidget, setTemplateWidget] = useState<
+ DashboardWidget | undefined
+ >();
+ const [pendingTemplateId, setPendingTemplateId] = useState<
+ string | undefined
+ >(templateIdParam);
+
+ // Redirect Readers away from edit mode
+ useEffect(() => {
+ if (systemRole === "reader") {
+ router.replace(`/${id}`);
+ }
+ }, [systemRole, id, router]);
+
+ // Load dashboard layout into store (migrates v1 → v2 if needed)
+ useEffect(() => {
+ if (dashboard?.layoutJson) {
+ const migrated = migrateLayout(dashboard.layoutJson);
+ const targetPage = pageParam !== undefined ? parseInt(pageParam, 10) : 0;
+ setLayout(migrated, isNaN(targetPage) ? 0 : targetPage);
+ }
+ }, [dashboard, setLayout, pageParam]);
+
+ const activePage = layout.pages[activePageIndex] ?? layout.pages[0];
+
+ const handleSave = useCallback(async () => {
+ setSaveError(null);
+ try {
+ // Sanitize: replace any y: Infinity from pending widget additions.
+ // react-grid-layout compacts these asynchronously, but if save fires
+ // before compaction completes the Infinity value gets persisted and
+ // all widgets collapse into a single vertical column on reload.
+ const sanitizedLayout = {
+ ...layout,
+ pages: layout.pages.map((page) => {
+ const hasInfinity = page.gridLayout.some(
+ (g) => !Number.isFinite(g.y),
+ );
+ if (!hasInfinity) return page;
+ const maxY = page.gridLayout.reduce(
+ (m, g) => (Number.isFinite(g.y) ? Math.max(m, g.y + g.h) : m),
+ 0,
+ );
+ let nextY = maxY;
+ return {
+ ...page,
+ gridLayout: page.gridLayout.map((g) => {
+ if (Number.isFinite(g.y)) return g;
+ const placed = { ...g, y: nextY };
+ nextY += g.h;
+ return placed;
+ }),
+ };
+ }),
+ };
+ await updateDashboard.mutateAsync({
+ id,
+ layoutJson: sanitizedLayout,
+ expectedVersion: dashboard?.version,
+ });
+ markSaved();
+
+ // Fire-and-forget: capture widget thumbnails from the active page's live DOM.
+ // Uses a short delay to let ECharts finish rendering after any layout changes.
+ const container = gridContainerRef.current;
+ const currentPage = activePage;
+ if (container && currentPage?.widgets.length) {
+ setTimeout(async () => {
+ try {
+ const thumbnails = await captureDashboardThumbnails(
+ container,
+ currentPage.widgets.map((w) => ({
+ id: w.id,
+ chartType: w.chartType,
+ })),
+ );
+ if (Object.keys(thumbnails).length > 0) {
+ updateThumbnails.mutate({ id, thumbnailJson: thumbnails });
+ }
+ } catch {
+ // Thumbnail capture failure is non-critical — silently ignore
+ }
+ }, 500);
+ }
+ } catch (error) {
+ setSaveError(
+ error instanceof Error ? error.message : "Failed to save dashboard",
+ );
+ }
+ }, [
+ id,
+ layout,
+ activePage,
+ updateDashboard,
+ updateThumbnails,
+ markSaved,
+ dashboard,
+ ]);
+
+ function openAddWidget() {
+ setEditorMode("add");
+ setEditingWidget(undefined);
+ setEditorOpen(true);
+ }
+
+ // ── Keyboard shortcuts ──────────────────────────────────────────
+ useKeyboardShortcuts([
+ {
+ shortcut: "Cmd+S",
+ handler: () => {
+ handleSave();
+ },
+ },
+ {
+ shortcut: "Cmd+E",
+ handler: () => {
+ // Route through unsaved-changes guard (same as the Back button)
+ if (requestNavigation("/" + id)) router.push("/" + id);
+ },
+ },
+ {
+ shortcut: "Cmd+Shift+N",
+ handler: openAddWidget,
+ disabled: editorOpen,
+ },
+ {
+ shortcut: "Escape",
+ handler: () => setEditorOpen(false),
+ disabled: !editorOpen,
+ },
+ ]);
+
+ const [cachedPreviewData, setCachedPreviewData] = useState<
+ { data: unknown; resultId: string } | undefined
+ >();
+
+ function openEditWidget(widget: DashboardWidget) {
+ // Grab cached query data so the editor preview shows instantly.
+ // Use getQueriesData with partial key — params vary with parameter store values.
+ const cachedEntries = queryClient.getQueriesData<{
+ data: unknown;
+ resultId: string;
+ }>({ queryKey: ["widget-query", widget.connectionId, widget.query] });
+ const cached = cachedEntries.length > 0 ? cachedEntries[0][1] : undefined;
+ setCachedPreviewData(cached ?? undefined);
+ setEditorMode("edit");
+ setEditingWidget(widget);
+ setEditorOpen(true);
+ }
+
+ function handleEditorSave(widget: DashboardWidget) {
+ if (editorMode === "add") {
+ const gridItem: GridLayoutItem = {
+ i: widget.id,
+ x: (activePage.gridLayout.length * 4) % 12,
+ y: Infinity,
+ w: 4,
+ h: 3,
+ };
+ addWidget(widget, gridItem);
+ } else {
+ updateWidget(widget.id, widget);
+ }
+ queryClient.invalidateQueries({
+ queryKey: ["widget-query", widget.connectionId, widget.query],
+ });
+ }
+
+ return (
+
+
+
+ {
+ if (requestNavigation(`/${id}`)) router.push(`/${id}`);
+ }}
+ >
+
+ Back
+
+
+
+
+ {isLoading ? "Loading…" : `Editing: ${dashboard?.name ?? ""}`}
+
+
+
+ {isAdmin && !isLoading && dashboard && (
+ <>
+
+
+
+
+ Sharing
+
+
+
+
+ Sharing
+
+
+ {
+ updateDashboard.mutate({ id, isPublic: value });
+ }}
+ />
+
+
+
+
+ >
+ )}
+ {!isLoading && dashboard && (
+ <>
+
+ setBarOverride((prev) => !(prev ?? effectiveShowBar))
+ }
+ aria-label={
+ effectiveShowBar ? "Hide parameters" : "Show parameters"
+ }
+ >
+
+ Filters
+ {hasParameters && parameterCount > 0 && (
+
+ {parameterCount}
+
+ )}
+
+
+
+
+ Add Widget
+
+
+
+
+ Save
+
+ >
+ )}
+
+
+
+ {isLoading && (
+
+
+
+
+ )}
+
+ {!isLoading && !dashboard && (
+
+
}
+ title="Dashboard not found"
+ description="The dashboard you're looking for doesn't exist or you don't have access."
+ action={
+
router.push("/")}>
+
+ Back to Dashboards
+
+ }
+ />
+
+ )}
+
+ {!isLoading && dashboard && (
+ <>
+ {saveError && (
+
+ )}
+
+
+
+
{
+ setEditorOpen(open);
+ if (!open && pendingTemplateId) {
+ setPendingTemplateId(undefined);
+ // Clean up the templateId search param from the URL
+ router.replace(`/${id}/edit`, { scroll: false });
+ }
+ }}
+ mode={editorMode}
+ widget={editingWidget}
+ connections={connections ?? []}
+ onSave={handleEditorSave}
+ layout={layout}
+ initialTemplate={
+ pendingTemplateId ? templateMap[pendingTemplateId] : undefined
+ }
+ initialPreviewData={
+ editorMode === "edit" ? cachedPreviewData : undefined
+ }
+ canWrite={session?.user?.canWrite !== false}
+ />
+
+ {templateWidget &&
+ (() => {
+ const conn = (connections ?? []).find(
+ (c) => c.id === templateWidget.connectionId,
+ );
+ // Content-only widgets (markdown, iframe) have no connection;
+ // default to "postgresql" so the template dialog still opens.
+ const connectorType: ConnectorType = conn
+ ? conn.type
+ : "postgresql";
+ return (
+ {
+ if (!open) setTemplateWidget(undefined);
+ }}
+ widget={templateWidget}
+ connectorType={connectorType}
+ />
+ );
+ })()}
+
+
+ {layout.pages.map((page, index) => {
+ const isActive = index === activePageIndex;
+ if (page.widgets.length === 0 && isActive) {
+ return (
+
}
+ title="No widgets yet"
+ description='Click "Add Widget" to get started.'
+ action={
+
+
+ Add Widget
+
+ }
+ />
+ );
+ }
+ if (page.widgets.length === 0) return null;
+ if (!isActive && !visitedPages.has(index)) return null;
+ return (
+
+ {
+ const target = page.widgets.find(
+ (w) => w.id === widgetId,
+ );
+ if (target)
+ updateWidget(widgetId, { ...target, settings });
+ },
+ onNavigateToPage: handleNavigateToPage,
+ onSaveAsTemplate: setTemplateWidget,
+ onSyncWidget: handleSyncWidget,
+ onDetachWidget: handleDetachWidget,
+ }}
+ templateMap={templateMap}
+ showParameterBar={effectiveShowBar}
+ parameterSourceMap={parameterSourceMap}
+ />
+
+ );
+ })}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/app/src/app/(dashboard)/[id]/page.tsx b/app/src/app/(dashboard)/[id]/page.tsx
new file mode 100644
index 000000000..de760f2ee
--- /dev/null
+++ b/app/src/app/(dashboard)/[id]/page.tsx
@@ -0,0 +1,587 @@
+"use client";
+
+import React, {
+ use,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ useTransition,
+} from "react";
+import { useRouter, useSearchParams, usePathname } from "next/navigation";
+import { useSession } from "next-auth/react";
+import {
+ ArrowLeft,
+ Filter,
+ Pencil,
+ LayoutDashboard,
+ RefreshCw,
+} from "lucide-react";
+import { useDashboard, useUpdateDashboard } from "@/hooks/use-dashboards";
+import { useParameterStore } from "@/stores/parameter-store";
+import { filterParentParams } from "@/lib/parameter/format-parameter-value";
+import { buildParameterSourceMap } from "@/lib/parameter/collect-parameter-names";
+import { scrollToWidgetWhenReady } from "@/lib/widget/scroll-to-widget";
+import { parseUrlParams, buildUrlParams } from "@/lib/shared/url-params";
+import { DashboardContainer } from "@/components/dashboard-container";
+import { DashboardErrorBoundary } from "@/components/dashboard-error-boundary";
+import { SaveTemplateDialog } from "@/components/save-template-dialog";
+import { useConnections } from "@/hooks/use-connections";
+import type { DashboardWidget } from "@/lib/db/schema";
+import { PageTabs } from "@/components/page-tabs";
+import { migrateLayout } from "@/lib/dashboard/migrate-layout";
+import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
+import { getRefetchInterval } from "@/lib/dashboard/dashboard-settings";
+import { useCountdown } from "@/hooks/use-countdown";
+import type { DashboardSettings } from "@/lib/db/schema";
+import {
+ Button,
+ Badge,
+ Skeleton,
+ Input,
+ LoadingButton,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@neoboard/components";
+import {
+ EmptyState,
+ TimeAgo,
+ Toolbar,
+ ToolbarSection,
+ ToolbarSeparator,
+} from "@neoboard/components";
+
+function formatInterval(seconds: number): string {
+ if (seconds < 60) return `${seconds}s`;
+ return `${Math.round(seconds / 60)}m`;
+}
+
+function formatCountdown(seconds: number): string {
+ if (seconds < 60) return `${seconds}s`;
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return `${m}:${String(s).padStart(2, "0")}`;
+}
+
+export default function DashboardViewerPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = use(params);
+ const router = useRouter();
+ const { data: session } = useSession();
+ const canWrite = session?.user?.canWrite !== false;
+ const saveToDashboard = useParameterStore((s) => s.saveToDashboard);
+ const restoreFromDashboard = useParameterStore((s) => s.restoreFromDashboard);
+ const prevDashboardId = useRef(null);
+
+ useEffect(() => {
+ if (prevDashboardId.current && prevDashboardId.current !== id) {
+ saveToDashboard(prevDashboardId.current);
+ }
+ prevDashboardId.current = id;
+ restoreFromDashboard(id);
+ return () => {
+ saveToDashboard(id);
+ };
+ }, [id, saveToDashboard, restoreFromDashboard]);
+
+ // URL parameter deep-linking: read URL params on mount (takes precedence)
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
+ const initialUrlParamsApplied = useRef(false);
+
+ useEffect(() => {
+ if (initialUrlParamsApplied.current) return;
+ initialUrlParamsApplied.current = true;
+ const urlParams = parseUrlParams(searchParams);
+ const store = useParameterStore.getState();
+ for (const [name, value] of Object.entries(urlParams)) {
+ store.setParameter(name, value, value, "", "text", "url", "");
+ }
+ }, [searchParams]);
+
+ // Sync parameter store changes → URL (shallow replace, no navigation)
+ useEffect(() => {
+ return useParameterStore.subscribe((state) => {
+ const values: Record = {};
+ for (const [key, entry] of Object.entries(state.parameters)) {
+ if (
+ entry?.value !== undefined &&
+ entry.value !== null &&
+ String(entry.value) !== ""
+ ) {
+ values[key] = entry.value;
+ }
+ }
+ const newParams = buildUrlParams(values);
+ const newUrl = newParams.toString()
+ ? `${pathname}?${newParams.toString()}`
+ : pathname;
+ router.replace(newUrl, { scroll: false });
+ });
+ }, [pathname, router]);
+
+ const { data: dashboard, isLoading, isFetching } = useDashboard(id);
+ const updateDashboard = useUpdateDashboard();
+
+ // Optimistic lock: detect when another user saves while we're viewing.
+ // Track the initial version via `useSyncExternalStore`-style pattern:
+ // a module-scoped map keyed by dashboard ID, populated on first data load.
+ const [versionBumpMsg, setVersionBumpMsg] = useState(null);
+
+ const dashboardVersion = dashboard?.version;
+ const dashboardUpdatedBy = dashboard?.updatedByName;
+
+ // Detect when another user saves while we're viewing. Compares the
+ // server's version to the one we saw on first load (sessionStorage).
+ useEffect(() => {
+ if (dashboardVersion === undefined) return;
+ const key = `__nb_dash_ver_${id}`;
+ const stored = sessionStorage.getItem(key);
+ if (stored === null) {
+ sessionStorage.setItem(key, String(dashboardVersion));
+ } else if (dashboardVersion > Number(stored)) {
+ sessionStorage.setItem(key, String(dashboardVersion));
+ const who = dashboardUpdatedBy ?? "someone";
+ setVersionBumpMsg(`Dashboard updated by ${who}`);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- only fire on version change
+ }, [dashboardVersion]);
+
+ const versionBump = versionBumpMsg;
+ const parameters = useParameterStore((s) => s.parameters);
+ const parameterCount = useMemo(
+ () => filterParentParams(Object.entries(parameters)).length,
+ [parameters],
+ );
+ // Button should be enabled whenever the dashboard has parameter widgets,
+ // even if the store hasn't populated their values yet (e.g. on initial load).
+ const hasParameterWidgets = useMemo(() => {
+ if (!dashboard) return false;
+ const migrated = migrateLayout(dashboard.layoutJson);
+ return migrated.pages.some((p) =>
+ p.widgets.some((w) => w.chartType === "parameter-select"),
+ );
+ }, [dashboard]);
+ const hasParameters = hasParameterWidgets || parameterCount > 0;
+ // null = auto mode (show when params exist), boolean = user override
+ const [barOverride, setBarOverride] = useState(null);
+ const effectiveShowBar = barOverride !== null ? barOverride : hasParameters;
+ const [templateWidget, setTemplateWidget] = useState<
+ DashboardWidget | undefined
+ >();
+ const { data: connectionsData } = useConnections();
+ const [activePageIndex, setActivePageIndex] = useState(0);
+ const [visitedPages, setVisitedPages] = useState>(
+ () => new Set([0]),
+ );
+
+ function markVisited(index: number) {
+ setVisitedPages((prev) => {
+ if (prev.has(index)) return prev;
+ const next = new Set(prev);
+ next.add(index);
+ return next;
+ });
+ }
+
+ function handleSelectPage(index: number) {
+ markVisited(index);
+ setActivePageIndex(index);
+ }
+ const [isPending, startTransition] = useTransition();
+ const layout = useMemo(
+ () => (dashboard ? migrateLayout(dashboard.layoutJson) : null),
+ [dashboard],
+ );
+
+ // Auto-refresh: local override (null = use persisted settings from layout).
+ // Keyed by dashboard id so navigating to a different dashboard resets the override.
+ const [localSettings, setLocalSettings] = useState<{
+ dashboardId: string;
+ settings: DashboardSettings;
+ } | null>(null);
+ const activeLocalSettings =
+ localSettings?.dashboardId === id ? localSettings.settings : null;
+ const autoRefreshSettings = activeLocalSettings ?? layout?.settings ?? {};
+ const refetchInterval = getRefetchInterval(autoRefreshSettings);
+
+ // Countdown to the next auto-refresh tick
+ const countdown = useCountdown(refetchInterval);
+
+ // Custom interval input state (seconds as string — validated on apply)
+ const [customSeconds, setCustomSeconds] = useState("");
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+
+ // Promise queue to serialize persist writes and prevent out-of-order saves
+ const persistQueueRef = useRef>(Promise.resolve());
+
+ const applyInterval = useCallback(
+ (seconds: number | "off") => {
+ const newSettings: DashboardSettings =
+ seconds === "off"
+ ? { autoRefresh: false }
+ : { autoRefresh: true, refreshIntervalSeconds: seconds };
+ setLocalSettings({ dashboardId: id, settings: newSettings });
+ if (layout) {
+ const payload = {
+ id,
+ layoutJson: { ...layout, settings: newSettings },
+ };
+ persistQueueRef.current = persistQueueRef.current
+ .catch(() => undefined)
+ .then(() => updateDashboard.mutateAsync(payload))
+ .catch((err: unknown) => {
+ console.error(
+ "[auto-save] Failed to persist dashboard settings:",
+ err,
+ );
+ });
+ }
+ },
+ [id, layout, updateDashboard],
+ );
+
+ const handleIntervalChange = useCallback(
+ (value: string) => {
+ applyInterval(value === "off" ? "off" : Number(value));
+ },
+ [applyInterval],
+ );
+
+ const handleCustomApply = useCallback(() => {
+ const s = parseInt(customSeconds, 10);
+ if (!Number.isFinite(s) || s < 5) return; // minimum 5s
+ applyInterval(s);
+ setCustomSeconds("");
+ setDropdownOpen(false);
+ }, [customSeconds, applyInterval]);
+
+ // Derive display values from the effective (normalized) interval
+ const effectiveSeconds =
+ typeof refetchInterval === "number" ? refetchInterval / 1000 : null;
+ const intervalLabel =
+ effectiveSeconds !== null
+ ? formatInterval(effectiveSeconds)
+ : "Auto-refresh";
+ const dropdownValue =
+ effectiveSeconds !== null ? String(effectiveSeconds) : "off";
+ // Toolbar button label: show interval + live countdown when active
+ const buttonLabel =
+ countdown !== null
+ ? `${intervalLabel} · ${formatCountdown(countdown)}`
+ : intervalLabel;
+
+ const parameterSourceMap = useMemo(
+ () => (layout ? buildParameterSourceMap(layout) : {}),
+ [layout],
+ );
+
+ const handleNavigateToPage = useCallback(
+ (pageId: string, scrollToWidgetId?: string) => {
+ if (!layout) return;
+ const index = layout.pages.findIndex((p) => p.id === pageId);
+ if (index >= 0) {
+ markVisited(index);
+ setActivePageIndex(index);
+ if (scrollToWidgetId) {
+ scrollToWidgetWhenReady(scrollToWidgetId);
+ }
+ }
+ },
+ [layout],
+ );
+
+ // ── Keyboard shortcuts ──────────────────────────────────────────
+ // Must be called before any early returns (React hook rules).
+ const canEdit =
+ dashboard?.role === "owner" ||
+ dashboard?.role === "editor" ||
+ dashboard?.role === "admin";
+ useKeyboardShortcuts([
+ {
+ shortcut: "Cmd+E",
+ handler: () => {
+ if (layout) {
+ const idx = Math.min(activePageIndex, layout.pages.length - 1);
+ router.push("/" + id + "/edit?page=" + idx);
+ }
+ },
+ disabled: !canEdit || !layout,
+ },
+ ]);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!dashboard) {
+ return (
+
+
}
+ title="Dashboard not found"
+ description="The dashboard you're looking for doesn't exist or you don't have access."
+ action={
+
router.push("/")}>
+
+ Back to Dashboards
+
+ }
+ />
+
+ );
+ }
+
+ // layout is non-null here because dashboard is defined (guarded above)
+ const resolvedLayout = layout!;
+ const safeIndex = Math.min(activePageIndex, resolvedLayout.pages.length - 1);
+
+ return (
+
+
+
+ router.push("/")}>
+
+ Back
+
+
+
+ {dashboard.name}
+ {dashboard.role}
+
+ · updated
+ {dashboard.updatedByName ? (
+ <> by {dashboard.updatedByName}>
+ ) : null}
+
+
+
+
+ setBarOverride((prev) => !(prev ?? effectiveShowBar))
+ }
+ aria-label={
+ effectiveShowBar ? "Hide parameters" : "Show parameters"
+ }
+ >
+
+ Filters
+ {hasParameters && parameterCount > 0 && (
+
+ {parameterCount}
+
+ )}
+
+ {canEdit && (
+ <>
+
+
+
+
+ {buttonLabel}
+
+
+
+ Auto-refresh
+
+
+
+ Off
+
+
+ 30 seconds
+
+
+ 1 minute
+
+
+ 5 minutes
+
+
+ 10 minutes
+
+
+
+
+
+ Custom (seconds)
+
+
+ ) =>
+ setCustomSeconds(e.target.value)
+ }
+ onKeyDown={(e: React.KeyboardEvent) => {
+ if (e.key === "Enter") handleCustomApply();
+ }}
+ className="h-7 text-xs"
+ data-testid="custom-interval-input"
+ />
+
+ Set
+
+
+
+
+
+
+
+ startTransition(() =>
+ router.push(`/${id}/edit?page=${safeIndex}`),
+ )
+ }
+ >
+
+ Edit
+
+ >
+ )}
+
+
+
+ {versionBump && (
+
+ {versionBump}
+ {
+ setVersionBumpMsg(null);
+ if (dashboardVersion !== undefined) {
+ sessionStorage.setItem(
+ `__nb_dash_ver_${id}`,
+ String(dashboardVersion),
+ );
+ }
+ router.refresh();
+ }}
+ >
+ Refresh
+
+
+ )}
+
+ {resolvedLayout.pages.length > 1 && (
+
+ )}
+
+
+
+ {resolvedLayout.pages.map((page, index) => {
+ const isActive = index === safeIndex;
+ if (page.widgets.length === 0 && isActive) {
+ return (
+
}
+ title="No widgets yet"
+ description="This page has no widgets."
+ action={
+ canEdit ? (
+
router.push(`/${id}/edit`)}>
+
+ Add widgets in the editor
+
+ ) : undefined
+ }
+ />
+ );
+ }
+ if (page.widgets.length === 0) return null;
+ if (!visitedPages.has(index)) return null;
+ return (
+
+
+
+ );
+ })}
+
+
+
+ {templateWidget &&
+ (() => {
+ const conn = (connectionsData ?? []).find(
+ (c: { id: string }) => c.id === templateWidget.connectionId,
+ );
+ const connectorType = (conn?.type ??
+ "neo4j") as import("@/lib/connector/connector-types").ConnectorType;
+ return (
+
{
+ if (!open) setTemplateWidget(undefined);
+ }}
+ widget={templateWidget}
+ connectorType={connectorType}
+ />
+ );
+ })()}
+
+ );
+}
diff --git a/app/src/app/(dashboard)/__tests__/layout.test.tsx b/app/src/app/(dashboard)/__tests__/layout.test.tsx
new file mode 100644
index 000000000..f4345f8c2
--- /dev/null
+++ b/app/src/app/(dashboard)/__tests__/layout.test.tsx
@@ -0,0 +1,260 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+
+/* ---------- mocks ---------- */
+
+const mockPush = vi.fn();
+const mockSignOut = vi.fn();
+const mockUseSession = vi.fn();
+
+vi.mock("next-auth/react", () => ({
+ useSession: (...args: unknown[]) => mockUseSession(...args),
+ signOut: (...args: unknown[]) => mockSignOut(...args),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: mockPush }),
+ usePathname: () => "/",
+}));
+
+vi.mock("@/hooks/use-theme", () => ({
+ useTheme: () => ({
+ preference: "system" as const,
+ resolvedTheme: "light" as const,
+ setTheme: vi.fn(),
+ }),
+}));
+
+vi.mock("@neoboard/components", () => ({
+ AppShell: ({
+ children,
+ sidebar,
+ }: {
+ children: React.ReactNode;
+ sidebar: React.ReactNode;
+ }) => (
+
+
{sidebar}
+
{children}
+
+ ),
+ Sidebar: ({
+ children,
+ footer,
+ }: {
+ children: React.ReactNode;
+ collapsed?: boolean;
+ onCollapsedChange?: (v: boolean) => void;
+ header?: React.ReactNode;
+ footer?: React.ReactNode;
+ }) => (
+
+ {children}
+ {footer}
+
+ ),
+ SidebarItem: ({
+ label,
+ icon,
+ onClick,
+ }: {
+ label: string;
+ icon?: React.ReactNode;
+ active?: boolean;
+ collapsed?: boolean;
+ onClick?: () => void;
+ }) => (
+
+ {icon}
+ {label}
+
+ ),
+ Badge: ({
+ children,
+ className,
+ }: {
+ children: React.ReactNode;
+ variant?: string;
+ className?: string;
+ }) => (
+
+ {children}
+
+ ),
+ DropdownMenu: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DropdownMenuTrigger: ({
+ children,
+ }: {
+ children: React.ReactNode;
+ asChild?: boolean;
+ }) => {children}
,
+ DropdownMenuContent: ({
+ children,
+ }: {
+ children: React.ReactNode;
+ side?: string;
+ align?: string;
+ }) => {children}
,
+ DropdownMenuRadioGroup: ({
+ children,
+ }: {
+ children: React.ReactNode;
+ value?: string;
+ onValueChange?: (v: string) => void;
+ }) => {children}
,
+ DropdownMenuRadioItem: ({
+ children,
+ }: {
+ children: React.ReactNode;
+ value?: string;
+ }) => {children}
,
+ DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DropdownMenuSeparator: () => ,
+}));
+
+/* ---------- import under test ---------- */
+import DashboardLayout from "../layout";
+
+/* ---------- tests ---------- */
+
+describe("DashboardLayout", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("shows loading spinner when session status is loading", () => {
+ mockUseSession.mockReturnValue({ data: null, status: "loading" });
+
+ const { container } = render(
+
+ Child content
+ ,
+ );
+
+ // Should show spinner, not content
+ expect(container.querySelector(".animate-spin")).toBeTruthy();
+ expect(screen.queryByText("Child content")).toBeNull();
+ });
+
+ it("renders children and sidebar when authenticated", () => {
+ mockUseSession.mockReturnValue({
+ data: { user: { name: "Alice", role: "admin" } },
+ status: "authenticated",
+ });
+
+ render(
+
+ Dashboard content
+ ,
+ );
+
+ expect(screen.getByText("Dashboard content")).toBeDefined();
+ expect(screen.getByTestId("sidebar")).toBeDefined();
+ });
+
+ it("displays user name in sidebar footer", () => {
+ mockUseSession.mockReturnValue({
+ data: { user: { name: "Alice Smith", role: "admin" } },
+ status: "authenticated",
+ });
+
+ render(
+
+ Content
+ ,
+ );
+
+ expect(screen.getByText("Alice Smith")).toBeDefined();
+ });
+
+ it("displays user role badge in sidebar footer", () => {
+ mockUseSession.mockReturnValue({
+ data: { user: { name: "Bob", role: "creator" } },
+ status: "authenticated",
+ });
+
+ render(
+
+ Content
+ ,
+ );
+
+ expect(screen.getByTestId("badge")).toBeDefined();
+ expect(screen.getByText("creator")).toBeDefined();
+ });
+
+ it("does not display role badge when role is empty", () => {
+ mockUseSession.mockReturnValue({
+ data: { user: { name: "Charlie" } },
+ status: "authenticated",
+ });
+
+ render(
+
+ Content
+ ,
+ );
+
+ expect(screen.getByText("Charlie")).toBeDefined();
+ expect(screen.queryByTestId("badge")).toBeNull();
+ });
+
+ it("does not display user identity section when name is empty", () => {
+ mockUseSession.mockReturnValue({
+ data: { user: { name: "" } },
+ status: "authenticated",
+ });
+
+ render(
+
+ Content
+ ,
+ );
+
+ // The user identity section should not render when userName is falsy
+ expect(screen.queryByTestId("badge")).toBeNull();
+ });
+
+ it("renders all expected sidebar navigation items", () => {
+ mockUseSession.mockReturnValue({
+ data: { user: { name: "Admin", role: "admin" } },
+ status: "authenticated",
+ });
+
+ render(
+
+ Content
+ ,
+ );
+
+ expect(screen.getByTestId("sidebar-item-Dashboards")).toBeDefined();
+ expect(screen.getByTestId("sidebar-item-Connections")).toBeDefined();
+ expect(screen.getByTestId("sidebar-item-Users")).toBeDefined();
+ expect(screen.getByTestId("sidebar-item-Widget Lab")).toBeDefined();
+ expect(screen.getByTestId("sidebar-item-Settings")).toBeDefined();
+ expect(screen.getByTestId("sidebar-item-Sign out")).toBeDefined();
+ expect(screen.getByTestId("sidebar-item-Theme")).toBeDefined();
+ });
+
+ it("calls onUnauthenticated callback to redirect to login", () => {
+ mockUseSession.mockImplementation(
+ ({ onUnauthenticated }: { onUnauthenticated: () => void }) => {
+ onUnauthenticated();
+ return { data: null, status: "loading" };
+ },
+ );
+
+ render(
+
+ Content
+ ,
+ );
+
+ expect(mockPush).toHaveBeenCalledWith("/login");
+ });
+});
diff --git a/app/src/app/(dashboard)/connections/page.tsx b/app/src/app/(dashboard)/connections/page.tsx
new file mode 100644
index 000000000..8c9caeba9
--- /dev/null
+++ b/app/src/app/(dashboard)/connections/page.tsx
@@ -0,0 +1,1156 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { Database, Plus, ChevronDown } from "lucide-react";
+import { Neo4jLogo, PostgreSQLLogo } from "@/components/db-logos";
+import {
+ useConnections,
+ useConnectionUsage,
+ useCreateConnection,
+ useUpdateConnection,
+ useDeleteConnection,
+ useReassignConnection,
+ useTestConnection,
+ useTestInlineConnection,
+} from "@/hooks/use-connections";
+import {
+ Button,
+ Input,
+ Label,
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+ Switch,
+} from "@neoboard/components";
+import {
+ PageHeader,
+ EmptyState,
+ LoadingButton,
+ LoadingOverlay,
+ ConfirmDialog,
+ ConnectionCard,
+ PasswordInput,
+ Alert,
+ AlertDescription,
+} from "@neoboard/components";
+import type { ConnectionState } from "@neoboard/components";
+import {
+ type ConnectorType,
+ CONNECTOR_LABELS,
+} from "@/lib/connector/connector-types";
+import {
+ parseOptionalInt,
+ mapConfigToEditForm,
+} from "@/lib/shared/parse-utils";
+
+type DialogStep = "pick-type" | "fill-form";
+
+const DEFAULT_FORM = {
+ name: "",
+ type: "neo4j" as ConnectorType,
+ uri: "",
+ username: "",
+ password: "",
+ database: "",
+ // Advanced settings (stored as strings for form input, parsed to numbers on submit)
+ connectionTimeout: "",
+ queryTimeout: "",
+ maxPoolSize: "",
+ connectionAcquisitionTimeout: "",
+ idleTimeout: "",
+ statementTimeout: "",
+ sslRejectUnauthorized: undefined as boolean | undefined,
+ maxRows: "",
+};
+
+export default function ConnectionsPage() {
+ const { data: connections, isLoading } = useConnections();
+ const createConnection = useCreateConnection();
+ const updateConnection = useUpdateConnection();
+ const deleteConnection = useDeleteConnection();
+ const testConnection = useTestConnection();
+
+ const testInline = useTestInlineConnection();
+ const [inlineTestResult, setInlineTestResult] = useState<{
+ success: boolean;
+ error?: string;
+ } | null>(null);
+
+ // Dialog state
+ const [showCreate, setShowCreate] = useState(false);
+ const [dialogStep, setDialogStep] = useState("pick-type");
+ const [form, setForm] = useState(DEFAULT_FORM);
+ const [testResults, setTestResults] = useState>({});
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ // Pre-fetch the usage breakdown whenever a delete is pending so the
+ // confirm dialog can render the list of affected dashboards + widget
+ // 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(null);
+ const [reassignChoice, setReassignChoice] = useState("");
+ const [reassignError, setReassignError] = useState(null);
+ const reassignConnection = useReassignConnection();
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const autoTestedRef = useRef(false);
+ const editTargetIdRef = useRef(null);
+
+ // Edit dialog state — only advanced settings are editable
+ const [editTarget, setEditTarget] = useState<{
+ id: string;
+ name: string;
+ type: ConnectorType;
+ } | null>(null);
+ const [editForm, setEditForm] = useState(DEFAULT_FORM);
+ const [editLoading, setEditLoading] = useState(false);
+ const [editError, setEditError] = useState(null);
+ const [showEditAdvanced, setShowEditAdvanced] = useState(true);
+
+ // Auto-test all connections on first load
+ useEffect(() => {
+ if (!connections?.length || autoTestedRef.current) return;
+ autoTestedRef.current = true;
+ for (const c of connections) {
+ handleTest(c.id);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [connections]);
+
+ const [createError, setCreateError] = useState(null);
+ const [testErrors, setTestErrors] = useState>({});
+ const [expandedErrorId, setExpandedErrorId] = useState(null);
+
+ function buildConfig() {
+ return {
+ uri: form.uri,
+ username: form.username,
+ password: form.password,
+ database: form.database || undefined,
+ connectionTimeout: parseOptionalInt(form.connectionTimeout),
+ queryTimeout: parseOptionalInt(form.queryTimeout),
+ maxPoolSize: parseOptionalInt(form.maxPoolSize),
+ connectionAcquisitionTimeout: parseOptionalInt(
+ form.connectionAcquisitionTimeout,
+ ),
+ idleTimeout: parseOptionalInt(form.idleTimeout),
+ statementTimeout: parseOptionalInt(form.statementTimeout),
+ sslRejectUnauthorized: form.sslRejectUnauthorized,
+ maxRows: parseOptionalInt(form.maxRows),
+ };
+ }
+
+ /** Render a numeric input field for advanced settings. */
+ function renderNumericField(
+ id: string,
+ label: string,
+ field: keyof typeof DEFAULT_FORM,
+ placeholder: string,
+ min: number,
+ max: number | undefined,
+ formState: typeof DEFAULT_FORM,
+ setFormState: React.Dispatch>,
+ ) {
+ return (
+
+ {label}
+ ) =>
+ setFormState((f) => ({ ...f, [field]: e.target.value }))
+ }
+ placeholder={placeholder}
+ />
+
+ );
+ }
+
+ function numericField(
+ id: string,
+ label: string,
+ field: keyof typeof DEFAULT_FORM,
+ placeholder: string,
+ min: number,
+ max?: number,
+ ) {
+ return renderNumericField(
+ id,
+ label,
+ field,
+ placeholder,
+ min,
+ max,
+ form,
+ setForm,
+ );
+ }
+
+ function editNumericField(
+ id: string,
+ label: string,
+ field: keyof typeof DEFAULT_FORM,
+ placeholder: string,
+ min: number,
+ max?: number,
+ ) {
+ return renderNumericField(
+ id,
+ label,
+ field,
+ placeholder,
+ min,
+ max,
+ editForm,
+ setEditForm,
+ );
+ }
+
+ function openCreateDialog(type?: ConnectorType) {
+ setForm({ ...DEFAULT_FORM, ...(type ? { type } : {}) });
+ setDialogStep(type ? "fill-form" : "pick-type");
+ setCreateError(null);
+ setInlineTestResult(null);
+ setShowCreate(true);
+ }
+
+ function closeCreateDialog() {
+ setShowCreate(false);
+ setDialogStep("pick-type");
+ setCreateError(null);
+ setInlineTestResult(null);
+ setShowAdvanced(false);
+ }
+
+ function handlePickType(type: ConnectorType) {
+ setForm((f) => ({ ...f, type }));
+ setDialogStep("fill-form");
+ }
+
+ async function handleCreate(e: React.FormEvent) {
+ e.preventDefault();
+ setCreateError(null);
+ try {
+ const newConn = await createConnection.mutateAsync({
+ name: form.name,
+ type: form.type,
+ config: buildConfig(),
+ });
+ closeCreateDialog();
+ handleTest(newConn.id);
+ } catch (error) {
+ setCreateError(
+ error instanceof Error ? error.message : "Failed to create connection",
+ );
+ }
+ }
+
+ async function handleTestInline() {
+ setInlineTestResult(null);
+ try {
+ const result = await testInline.mutateAsync({
+ type: form.type,
+ config: buildConfig(),
+ });
+ setInlineTestResult(result);
+ } catch {
+ setInlineTestResult({ success: false, error: "Connection test failed" });
+ }
+ }
+
+ async function handleTest(id: string) {
+ setTestResults((prev) => ({ ...prev, [id]: "connecting" }));
+ setTestErrors((prev) => {
+ const next = { ...prev };
+ delete next[id];
+ return next;
+ });
+ try {
+ const result = await testConnection.mutateAsync(id);
+ setTestResults((prev) => ({
+ ...prev,
+ [id]: result.success ? "connected" : "error",
+ }));
+ if (!result.success && result.error) {
+ setTestErrors((prev) => ({ ...prev, [id]: result.error! }));
+ }
+ } catch {
+ setTestResults((prev) => ({ ...prev, [id]: "error" }));
+ setTestErrors((prev) => ({ ...prev, [id]: "Connection test failed" }));
+ }
+ }
+
+ function handleDuplicate(conn: { name: string; type: ConnectorType }) {
+ setForm({
+ ...DEFAULT_FORM,
+ type: conn.type,
+ name: `${conn.name} (copy)`,
+ });
+ setDialogStep("fill-form");
+ setCreateError(null);
+ setInlineTestResult(null);
+ setShowCreate(true);
+ }
+
+ async function openEditDialog(conn: {
+ id: string;
+ name: string;
+ type: ConnectorType;
+ }) {
+ editTargetIdRef.current = conn.id;
+ setEditTarget(conn);
+ setEditForm({ ...DEFAULT_FORM, type: conn.type, name: conn.name });
+ setEditError(null);
+ setEditLoading(true);
+ setShowEditAdvanced(true);
+
+ // Fetch existing config (sans password) and pre-fill the form.
+ // Guard against races: if the user opens a different connection before this
+ // fetch completes, discard the stale response.
+ const controller = new AbortController();
+ try {
+ const res = await fetch(`/api/connections/${conn.id}`, {
+ signal: controller.signal,
+ });
+ if (editTargetIdRef.current !== conn.id) return; // stale response
+ const body = await res.json();
+ const config = body?.data?.config;
+ if (config) {
+ setEditForm((prev) => ({
+ ...prev,
+ ...mapConfigToEditForm(config),
+ }));
+ }
+ } catch {
+ // Non-critical — form still works with empty fields
+ } finally {
+ setEditLoading(false);
+ }
+ }
+
+ function buildEditConfig() {
+ // Only include credential fields when the user has explicitly filled them in.
+ // Omitting them (undefined) tells the server to keep the existing stored values
+ // rather than overwriting them with blank strings.
+ return {
+ ...(editForm.uri ? { uri: editForm.uri } : {}),
+ ...(editForm.username ? { username: editForm.username } : {}),
+ ...(editForm.password ? { password: editForm.password } : {}),
+ database: editForm.database || undefined,
+ connectionTimeout: parseOptionalInt(editForm.connectionTimeout),
+ queryTimeout: parseOptionalInt(editForm.queryTimeout),
+ maxPoolSize: parseOptionalInt(editForm.maxPoolSize),
+ connectionAcquisitionTimeout: parseOptionalInt(
+ editForm.connectionAcquisitionTimeout,
+ ),
+ idleTimeout: parseOptionalInt(editForm.idleTimeout),
+ statementTimeout: parseOptionalInt(editForm.statementTimeout),
+ sslRejectUnauthorized: editForm.sslRejectUnauthorized,
+ maxRows: parseOptionalInt(editForm.maxRows),
+ };
+ }
+
+ async function handleEdit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!editTarget) return;
+ setEditError(null);
+
+ try {
+ await updateConnection.mutateAsync({
+ id: editTarget.id,
+ config: buildEditConfig(),
+ });
+ setEditTarget(null);
+ handleTest(editTarget.id);
+ } catch (error) {
+ setEditError(
+ error instanceof Error ? error.message : "Failed to update connection",
+ );
+ }
+ }
+
+ function getConnectionStatus(id: string): ConnectionState {
+ const result = testResults[id];
+ if (result === "connecting") return "connecting";
+ if (result === "connected") return "connected";
+ if (result === "error") return "error";
+ return "disconnected";
+ }
+
+ return (
+
+
openCreateDialog()}>
+
+ Add Connection
+
+ }
+ />
+
+ {
+ if (!open) closeCreateDialog();
+ }}
+ >
+
+ {dialogStep === "pick-type" ? (
+ <>
+
+ Choose Database Type
+
+
+
handlePickType("neo4j")}
+ className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-border p-6 text-center transition-colors hover:border-primary hover:bg-accent cursor-pointer"
+ >
+
+
+
Neo4j
+
+ Graph database
+
+
+
+
handlePickType("postgresql")}
+ className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-border p-6 text-center transition-colors hover:border-primary hover:bg-accent cursor-pointer"
+ >
+
+
+
PostgreSQL
+
+ Relational database
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ {/* Edit dialog — advanced options + credentials (required to re-encrypt) */}
+ {
+ if (!open) setEditTarget(null);
+ }}
+ >
+
+
+
+
+
+ {
+ if (!open) setDeleteTarget(null);
+ }}
+ title="Delete Connection"
+ description={
+ deleteUsage.isLoading ? (
+ "Checking widgets that use this connection…"
+ ) : deleteUsage.isError ? (
+ "Could not verify widget usage. You may proceed, but some widgets may stop working."
+ ) : deleteUsage.data && deleteUsage.data.widgetCount > 0 ? (
+
+
+ This connection is used by{" "}
+
+ {deleteUsage.data.widgetCount} widget
+ {deleteUsage.data.widgetCount === 1 ? "" : "s"}
+ {" "}
+ on{" "}
+
+ {deleteUsage.data.dashboards.length} dashboard
+ {deleteUsage.data.dashboards.length === 1 ? "" : "s"}
+
+ . Deleting it will break them:
+
+
+ {deleteUsage.data.dashboards.slice(0, 10).map((d) => (
+
+ {d.name} {" "}
+
+ ({d.widgetCount} widget
+ {d.widgetCount === 1 ? "" : "s"})
+
+
+ ))}
+ {deleteUsage.data.dashboards.length > 10 && (
+
+ +{deleteUsage.data.dashboards.length - 10} more…
+
+ )}
+
+
{
+ if (!deleteTarget) return;
+ setReassignTarget(deleteTarget);
+ setReassignChoice("");
+ setReassignError(null);
+ setDeleteTarget(null);
+ }}
+ >
+ Re-assign widgets to another connection…
+
+
+ ) : (
+ "This connection is not used by any widget. It will be permanently deleted."
+ )
+ }
+ confirmText={
+ deleteUsage.data && deleteUsage.data.widgetCount > 0
+ ? "Delete anyway"
+ : "Delete"
+ }
+ confirmDisabled={deleteUsage.isLoading}
+ variant="destructive"
+ onConfirm={() => {
+ if (deleteTarget) {
+ const force =
+ !!deleteUsage.data && deleteUsage.data.widgetCount > 0;
+ deleteConnection.mutate({ id: deleteTarget, force });
+ setDeleteTarget(null);
+ }
+ }}
+ />
+
+ {
+ if (!open) {
+ setReassignTarget(null);
+ setReassignChoice("");
+ setReassignError(null);
+ }
+ }}
+ >
+
+
+ Re-assign widgets
+
+ {(() => {
+ const sourceConn =
+ reassignTarget != null
+ ? connections?.find((c) => c.id === reassignTarget)
+ : null;
+ const compatible = (connections ?? []).filter(
+ (c) =>
+ c.id !== reassignTarget &&
+ sourceConn &&
+ c.type === sourceConn.type,
+ );
+ return (
+
+
+ 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.
+
+ {compatible.length === 0 ? (
+
+
+ No compatible {sourceConn?.type ?? ""} connections
+ available. Create one first.
+
+
+ ) : (
+
+ Target connection
+ setReassignChoice(e.target.value)}
+ >
+ Select a connection…
+ {compatible.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+ )}
+ {reassignError && (
+
+ {reassignError}
+
+ )}
+
+ );
+ })()}
+
+ setReassignTarget(null)}>
+ Cancel
+
+ {
+ 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
+
+
+
+
+
+
+
+ {connections?.length ? (
+
+ {connections.map((c) => {
+ const status = getConnectionStatus(c.id);
+ return (
+
+
+ setExpandedErrorId((prev) =>
+ prev === c.id ? null : c.id,
+ )
+ : undefined
+ }
+ onTest={() => handleTest(c.id)}
+ onEdit={() => openEditDialog(c)}
+ onDelete={() => setDeleteTarget(c.id)}
+ onDuplicate={() => handleDuplicate(c)}
+ />
+ {expandedErrorId === c.id && testErrors[c.id] && (
+
+ {testErrors[c.id]}
+
+ )}
+
+ );
+ })}
+
+ ) : (
+ }
+ title="No connections yet"
+ description="Add your first database connection to start querying data."
+ action={
+ openCreateDialog()}>
+
+ Add your first connection
+
+ }
+ />
+ )}
+
+
+
+ );
+}
diff --git a/app/src/app/(dashboard)/dashboards/page.tsx b/app/src/app/(dashboard)/dashboards/page.tsx
new file mode 100644
index 000000000..af1026b7d
--- /dev/null
+++ b/app/src/app/(dashboard)/dashboards/page.tsx
@@ -0,0 +1,10 @@
+import { redirect } from "next/navigation";
+
+/**
+ * Static route for /dashboards — redirects to / (the actual dashboard list).
+ * Without this, the [id] dynamic segment captures "dashboards" as a dashboard ID,
+ * resulting in a 404 from /api/dashboards/dashboards.
+ */
+export default function DashboardsRedirect() {
+ redirect("/");
+}
diff --git a/app/src/app/(dashboard)/layout.tsx b/app/src/app/(dashboard)/layout.tsx
new file mode 100644
index 000000000..d9b7dc4f4
--- /dev/null
+++ b/app/src/app/(dashboard)/layout.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter, usePathname } from "next/navigation";
+import { signOut, useSession } from "next-auth/react";
+import {
+ LayoutDashboard,
+ Database,
+ Users,
+ LogOut,
+ FlaskConical,
+ Moon,
+ Sun,
+ Monitor,
+ Settings,
+ User,
+} from "lucide-react";
+import { useTheme } from "@/hooks/use-theme";
+import type { ThemePreference } from "@/hooks/use-theme";
+import {
+ AppShell,
+ Sidebar,
+ SidebarItem,
+ Badge,
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+} from "@neoboard/components";
+
+const themeOptions = [
+ { value: "light" as const, icon: Sun, label: "Light" },
+ { value: "dark" as const, icon: Moon, label: "Dark" },
+ { value: "system" as const, icon: Monitor, label: "System" },
+];
+
+function getPreferenceIcon(preference: ThemePreference) {
+ const option = themeOptions.find((o) => o.value === preference);
+ const Icon = option?.icon ?? Monitor;
+ return ;
+}
+
+export default function DashboardLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const [collapsed, setCollapsed] = useState(false);
+ const { preference, setTheme } = useTheme();
+ const { data: session, status } = useSession({
+ required: true,
+ onUnauthenticated() {
+ router.push("/login");
+ },
+ });
+ const userName = session?.user?.name ?? "";
+ const userRole = (session?.user as { role?: string } | undefined)?.role ?? "";
+
+ // Don't render anything until we know the user is authenticated
+ if (status === "loading") {
+ return (
+
+ );
+ }
+
+ return (
+ NeoBoard
+ ) : (
+ N
+ )
+ }
+ footer={
+ <>
+ {userName && (
+ router.push("/settings/profile")}
+ aria-label="Account menu"
+ className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground transition-colors ${collapsed ? "justify-center" : ""}`}
+ >
+
+ {!collapsed && (
+
+ {userName}
+ {userRole && (
+
+ {userRole}
+
+ )}
+
+ )}
+
+ )}
+
+
+ {/*
+ No wrapping — SidebarItem's root is already a
+ button, and it forwards rest props (aria-haspopup, onClick,
+ etc.) so Radix's asChild trigger plumbing flows through
+ cleanly. A wrapping would produce invalid nested
+ button HTML and a React hydration warning.
+ */}
+
+
+
+ Theme
+
+ setTheme(v as ThemePreference)}
+ >
+ {themeOptions.map(({ value, icon: Icon, label }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+ }
+ label="Sign out"
+ collapsed={collapsed}
+ onClick={() => signOut()}
+ />
+ >
+ }
+ >
+ }
+ label="Dashboards"
+ active={pathname === "/"}
+ collapsed={collapsed}
+ onClick={() => router.push("/")}
+ />
+ }
+ label="Connections"
+ active={pathname === "/connections"}
+ collapsed={collapsed}
+ onClick={() => router.push("/connections")}
+ />
+ {userRole === "admin" && (
+ }
+ label="Users"
+ active={pathname === "/users"}
+ collapsed={collapsed}
+ onClick={() => router.push("/users")}
+ />
+ )}
+ }
+ label="Widget Lab"
+ active={pathname === "/widget-lab"}
+ collapsed={collapsed}
+ onClick={() => router.push("/widget-lab")}
+ />
+ }
+ label="Settings"
+ active={pathname.startsWith("/settings")}
+ collapsed={collapsed}
+ onClick={() => router.push("/settings/profile")}
+ />
+
+ }
+ >
+ {children}
+
+ );
+}
diff --git a/app/src/app/(dashboard)/page.tsx b/app/src/app/(dashboard)/page.tsx
new file mode 100644
index 000000000..50d8ebbec
--- /dev/null
+++ b/app/src/app/(dashboard)/page.tsx
@@ -0,0 +1,737 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useSession } from "next-auth/react";
+import Link from "next/link";
+import {
+ Plus,
+ LayoutDashboard,
+ MoreVertical,
+ Pencil,
+ Copy,
+ Trash2,
+ Grid2X2,
+ Globe,
+ Upload,
+ Download,
+ Database,
+ BarChart3,
+ BookOpen,
+ ArrowRight,
+} from "lucide-react";
+import {
+ useDashboards,
+ useCreateDashboard,
+ useDeleteDashboard,
+ useDuplicateDashboard,
+ useImportDashboard,
+} from "@/hooks/use-dashboards";
+import { useConnections } from "@/hooks/use-connections";
+import {
+ Button,
+ Input,
+ Badge,
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardFooter,
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+ Label,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@neoboard/components";
+import {
+ PageHeader,
+ EmptyState,
+ LoadingButton,
+ LoadingOverlay,
+ ConfirmDialog,
+ TimeAgo,
+ DashboardMiniPreview,
+} from "@neoboard/components";
+import { isNeoDashFormat } from "@/lib/dashboard/neodash-converter";
+
+// ── Types for import dialog ──────────────────────────────────────────
+
+interface ConnectionInfo {
+ name: string;
+ type: string;
+}
+
+interface ParsedImport {
+ payload: unknown;
+ dashboardName: string;
+ widgetCount: number;
+ isNeoDash: boolean;
+ connections: Record;
+}
+
+// ── triggerExport helper ─────────────────────────────────────────────
+
+async function triggerExport(id: string, name: string) {
+ const res = await fetch(`/api/dashboards/${id}/export`);
+ if (!res.ok) {
+ throw new Error(`Failed to export dashboard (${res.status})`);
+ }
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ const slug = name
+ .toLowerCase()
+ .replaceAll(/[^a-z0-9]+/g, "-")
+ .replaceAll(/^-|-$/g, "");
+ a.href = url;
+ a.download = `dashboard-${slug}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+// ── ImportDashboardDialog ─────────────────────────────────────────────
+
+interface ImportDashboardDialogProps {
+ readonly open: boolean;
+ readonly onOpenChange: (open: boolean) => void;
+}
+
+function ImportDashboardDialog({
+ open,
+ onOpenChange,
+}: ImportDashboardDialogProps) {
+ const router = useRouter();
+ const fileInputRef = useRef(null);
+ const [parsed, setParsed] = useState(null);
+ const [mapping, setMapping] = useState>({});
+ const [fileError, setFileError] = useState(null);
+
+ const { data: availableConnections = [] } = useConnections();
+ const importDashboard = useImportDashboard();
+
+ function reset() {
+ setParsed(null);
+ setMapping({});
+ setFileError(null);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ }
+
+ function handleOpenChange(isOpen: boolean) {
+ if (!isOpen) reset();
+ onOpenChange(isOpen);
+ }
+
+ async function handleFile(e: React.ChangeEvent) {
+ setFileError(null);
+ setParsed(null);
+ setMapping({});
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ try {
+ const text = await file.text();
+ const json = JSON.parse(text);
+
+ if (isNeoDashFormat(json)) {
+ // NeoDash — no connection mapping needed
+ const widgetCount =
+ (json.pages as Array<{ reports?: unknown[] }>)?.reduce(
+ (sum: number, p) => sum + (p.reports?.length ?? 0),
+ 0,
+ ) ?? 0;
+ setParsed({
+ payload: json,
+ dashboardName:
+ (json as { title?: string }).title ?? "Imported Dashboard",
+ widgetCount: widgetCount,
+ isNeoDash: true,
+ connections: {},
+ });
+ } else if (json.formatVersion === 1) {
+ // NeoBoard export
+ const connections = (json.connections ?? {}) as Record<
+ string,
+ ConnectionInfo
+ >;
+ const widgetCount =
+ (json.layout?.pages as Array<{ widgets?: unknown[] }>)?.reduce(
+ (sum: number, p) => sum + (p.widgets?.length ?? 0),
+ 0,
+ ) ?? 0;
+ const initialMapping: Record = {};
+ for (const key of Object.keys(connections)) {
+ initialMapping[key] = "";
+ }
+ setMapping(initialMapping);
+ setParsed({
+ payload: json,
+ dashboardName: json.dashboard?.name ?? "Imported Dashboard",
+ widgetCount,
+ isNeoDash: false,
+ connections,
+ });
+ } else {
+ setFileError(
+ "Unrecognised file format. Expected a NeoBoard or NeoDash export.",
+ );
+ }
+ } catch {
+ setFileError("Failed to parse file. Make sure it is a valid JSON file.");
+ }
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!parsed) return;
+
+ try {
+ const result = await importDashboard.mutateAsync({
+ payload: parsed.payload,
+ connectionMapping: mapping,
+ });
+ handleOpenChange(false);
+ router.push(`/${result.id}`);
+ } catch (error) {
+ setFileError(
+ error instanceof Error ? error.message : "Failed to import dashboard.",
+ );
+ }
+ }
+
+ const hasConnections =
+ parsed && !parsed.isNeoDash && Object.keys(parsed.connections).length > 0;
+ const allMapped =
+ !hasConnections || Object.values(mapping).every((v) => v !== "");
+
+ return (
+
+
+
+
+
+ );
+}
+
+// ── GettingStartedGuide ──────────────────────────────────────────────
+
+interface GettingStartedGuideProps {
+ readonly onCreateDashboard: () => void;
+}
+
+function GettingStartedGuide({ onCreateDashboard }: GettingStartedGuideProps) {
+ return (
+
+
+
+
+
+
Welcome to NeoBoard
+
+ Build dashboards that connect to your Neo4j and PostgreSQL databases.
+ Get started in three simple steps.
+
+
+
+
+
+
+
+
+
+
+
+ 1.
+ Add a connection
+
+
+ Connect to your Neo4j or PostgreSQL database so NeoBoard can query
+ your data.
+
+
+
+
+ Go to Connections
+
+
+
+
+
+
+
+
+
+
+
+ 2.
+ Create a dashboard
+
+
+ Give your dashboard a name and pick the layout that fits your
+ story.
+
+
+
+
+ Start now
+
+
+
+
+
+
+
+
+
+
+
+ 3.
+ Add widgets
+
+
+ Write a Cypher or SQL query, pick a chart type, and visualize your
+ results.
+
+
+
+
+ Widget guide
+
+
+
+
+
+
+ );
+}
+
+// ── Main page ─────────────────────────────────────────────────────────
+
+export default function DashboardListPage() {
+ const router = useRouter();
+ const { data: session } = useSession();
+ const systemRole = session?.user?.role ?? "creator";
+
+ const { data: dashboardList, isLoading } = useDashboards();
+ const createDashboard = useCreateDashboard();
+ const deleteDashboard = useDeleteDashboard();
+ const duplicateDashboard = useDuplicateDashboard();
+ const [newName, setNewName] = useState("");
+ const [nameError, setNameError] = useState(null);
+ const [showCreate, setShowCreate] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState<{
+ id: string;
+ name: string;
+ } | null>(null);
+ const [showImport, setShowImport] = useState(false);
+
+ const canCreate = systemRole === "admin" || systemRole === "creator";
+
+ async function handleCreate(e: React.FormEvent) {
+ e.preventDefault();
+ if (!newName.trim()) {
+ setNameError("Name is required");
+ return;
+ }
+ setNameError(null);
+ const dashboard = await createDashboard.mutateAsync({ name: newName });
+ setNewName("");
+ setShowCreate(false);
+ router.push(`/${dashboard.id}/edit`);
+ }
+
+ return (
+
+
+ setShowImport(true)}>
+
+ Import
+
+ setShowCreate(true)}>
+
+ New Dashboard
+
+
+ ) : undefined
+ }
+ />
+
+ {
+ setShowCreate(open);
+ if (!open) {
+ setNewName("");
+ setNameError(null);
+ }
+ }}
+ >
+
+
+
+
+
+ {
+ if (!open) setDeleteTarget(null);
+ }}
+ title={`Delete "${deleteTarget?.name ?? "Dashboard"}"?`}
+ description="This action cannot be undone. This will permanently delete this dashboard and all its widgets."
+ confirmText="Delete"
+ variant="destructive"
+ onConfirm={() => {
+ if (deleteTarget) {
+ deleteDashboard.mutate(deleteTarget.id);
+ setDeleteTarget(null);
+ }
+ }}
+ />
+
+
+
+
+
+ {!dashboardList?.length ? (
+ canCreate ? (
+ setShowCreate(true)}
+ />
+ ) : (
+ }
+ title="No dashboards yet"
+ description="No dashboards have been assigned to you yet."
+ />
+ )
+ ) : (
+
+ {dashboardList.map((d) => {
+ const canEdit =
+ d.role === "owner" ||
+ d.role === "editor" ||
+ d.role === "admin";
+ const canDelete = d.role === "owner" || d.role === "admin";
+ const canDuplicate = systemRole !== "reader";
+
+ return (
+
router.push(`/${d.id}`)}
+ >
+
+
+
+ {d.name}
+
+
+ {(canEdit || canDuplicate || canDelete) && (
+
+
+ e.stopPropagation()}
+ >
+
+
+ Dashboard options
+
+
+
+ e.stopPropagation()}
+ >
+ {canEdit && (
+ router.push(`/${d.id}/edit`)}
+ >
+
+ Edit
+
+ )}
+ {canDuplicate && (
+
+ duplicateDashboard.mutate(d.id)
+ }
+ disabled={duplicateDashboard.isPending}
+ >
+
+ Duplicate
+
+ )}
+ {
+ void triggerExport(d.id, d.name).catch(
+ (err) => {
+ console.error("Export failed", err);
+ },
+ );
+ }}
+ >
+
+ Export
+
+ {canDelete && (
+ <>
+
+
+ setDeleteTarget({
+ id: d.id,
+ name: d.name,
+ })
+ }
+ >
+
+ Delete
+
+ >
+ )}
+
+
+ )}
+ {d.isPublic && (
+
+ )}
+
{d.role}
+
+
+ {d.description && (
+
+ {d.description}
+
+ )}
+
+
+
+
+
+
+
+ {d.updatedByName && (
+ by {d.updatedByName}
+ )}
+
+
+
+ {d.widgetCount ?? 0} widget
+ {(d.widgetCount ?? 0) !== 1 ? "s" : ""}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/src/app/(dashboard)/settings/__tests__/page.test.ts b/app/src/app/(dashboard)/settings/__tests__/page.test.ts
new file mode 100644
index 000000000..52379d034
--- /dev/null
+++ b/app/src/app/(dashboard)/settings/__tests__/page.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect, vi } from "vitest";
+
+/* ---------- mocks ---------- */
+
+const mockRedirect = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ redirect: (...args: unknown[]) => mockRedirect(...args),
+}));
+
+/* ---------- import under test ---------- */
+import SettingsPage from "../page";
+
+/* ---------- tests ---------- */
+
+describe("SettingsPage", () => {
+ it("redirects to /settings/profile", () => {
+ SettingsPage();
+ expect(mockRedirect).toHaveBeenCalledWith("/settings/profile");
+ });
+
+ it("calls redirect exactly once", () => {
+ mockRedirect.mockClear();
+ SettingsPage();
+ expect(mockRedirect).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/src/app/(dashboard)/settings/api-keys/page.tsx b/app/src/app/(dashboard)/settings/api-keys/page.tsx
new file mode 100644
index 000000000..91104b949
--- /dev/null
+++ b/app/src/app/(dashboard)/settings/api-keys/page.tsx
@@ -0,0 +1,284 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { Plus, Trash2, Copy, Check, Key } from "lucide-react";
+import {
+ PageHeader,
+ Button,
+ Input,
+ EmptyState,
+ ConfirmDialog,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@neoboard/components";
+import { useApiKeys, useCreateApiKey, useRevokeApiKey } from "@/hooks/use-api-keys";
+import type { ApiKeyListItem, CreatedApiKey } from "@/hooks/use-api-keys";
+
+function formatDate(dateStr: string | null): string {
+ if (!dateStr) return "—";
+ return new Date(dateStr).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+function CopyButton({ value }: Readonly<{ value: string }>) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function CreateKeyDialog({
+ open,
+ onClose,
+}: Readonly<{
+ open: boolean;
+ onClose: () => void;
+}>) {
+ const [name, setName] = useState("");
+ const [expiresAt, setExpiresAt] = useState("");
+ const [createdKey, setCreatedKey] = useState(null);
+ const createMutation = useCreateApiKey();
+ const closedRef = useRef(false);
+
+ const handleCreate = async () => {
+ closedRef.current = false;
+ const result = await createMutation.mutateAsync({
+ name,
+ expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined,
+ });
+ // Guard against late response after dialog was closed
+ if (!closedRef.current) {
+ setCreatedKey(result);
+ }
+ };
+
+ const handleClose = () => {
+ closedRef.current = true;
+ setName("");
+ setExpiresAt("");
+ setCreatedKey(null);
+ createMutation.reset();
+ onClose();
+ };
+
+ return (
+ !o && handleClose()}>
+
+
+
+ {createdKey ? "API Key Created" : "Create API Key"}
+
+
+
+ {createdKey ? (
+
+
+ Copy this key now. It will not be shown again.
+
+
+ {createdKey.key}
+
+
+
+ Done
+
+
+ ) : (
+
+
+ Enter a name and optional expiry date for your new API key.
+
+
+
+ Name *
+
+ setName(e.target.value)}
+ />
+
+
+
+ Expires at (optional)
+
+ setExpiresAt(e.target.value)}
+ />
+
+ {createMutation.error && (
+
{createMutation.error.message}
+ )}
+
+
+ Cancel
+
+
+ {createMutation.isPending ? "Generating..." : "Generate Key"}
+
+
+
+ )}
+
+
+ );
+}
+
+function ApiKeyRow({
+ apiKey,
+ onRevoke,
+}: Readonly<{
+ apiKey: ApiKeyListItem;
+ onRevoke: (id: string) => void;
+}>) {
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ return (
+
+ {apiKey.name}
+
+ {formatDate(apiKey.createdAt)}
+
+
+ {formatDate(apiKey.lastUsedAt)}
+
+
+ {formatDate(apiKey.expiresAt)}
+
+
+ setConfirmOpen(true)}
+ aria-label={`Revoke ${apiKey.name}`}
+ >
+
+
+ {
+ onRevoke(apiKey.id);
+ setConfirmOpen(false);
+ }}
+ />
+
+
+ );
+}
+
+export default function ApiKeysPage() {
+ const [createOpen, setCreateOpen] = useState(false);
+ const { data: keys = [], isLoading } = useApiKeys();
+ const revokeMutation = useRevokeApiKey();
+
+ const handleRevoke = (id: string) => {
+ revokeMutation.mutate(id);
+ };
+
+ return (
+
+
setCreateOpen(true)}>
+
+ Create API Key
+
+ }
+ />
+
+ {isLoading && (
+
+ )}
+
+ {!isLoading && keys.length === 0 && (
+ }
+ title="No API keys"
+ description="Create an API key to make programmatic requests to NeoBoard."
+ action={
+ setCreateOpen(true)}>
+
+ Create API Key
+
+ }
+ />
+ )}
+
+ {!isLoading && keys.length > 0 && (
+
+
+
+
+
+ Name
+
+
+ Created
+
+
+ Last Used
+
+
+ Expires
+
+
+
+
+
+ {keys.map((key) => (
+
+ ))}
+
+
+
+ )}
+
+ setCreateOpen(false)} />
+
+ );
+}
diff --git a/app/src/app/(dashboard)/settings/authentication/__tests__/page.test.tsx b/app/src/app/(dashboard)/settings/authentication/__tests__/page.test.tsx
new file mode 100644
index 000000000..434f5e8a0
--- /dev/null
+++ b/app/src/app/(dashboard)/settings/authentication/__tests__/page.test.tsx
@@ -0,0 +1,320 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import React from "react";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockUseSsoProviders = vi.fn();
+const mockCreateMutate = vi.fn();
+const mockDeleteMutate = vi.fn();
+
+vi.mock("@/hooks/use-sso-providers", () => ({
+ useSsoProviders: () => mockUseSsoProviders(),
+ useCreateSsoProvider: () => ({
+ mutateAsync: mockCreateMutate,
+ isPending: false,
+ error: null,
+ reset: vi.fn(),
+ }),
+ useDeleteSsoProvider: () => ({
+ mutate: mockDeleteMutate,
+ isPending: false,
+ }),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn() }),
+ usePathname: () => "/settings/authentication",
+}));
+
+vi.mock("@neoboard/components", () => ({
+ PageHeader: ({
+ title,
+ description,
+ actions,
+ }: {
+ title: string;
+ description: string;
+ actions: React.ReactNode;
+ }) => (
+
+
{title}
+
{description}
+ {actions}
+
+ ),
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ ...rest
+ }: React.ButtonHTMLAttributes & {
+ variant?: string;
+ size?: string;
+ }) => (
+
+ {children}
+
+ ),
+ Input: (props: React.InputHTMLAttributes) => (
+
+ ),
+ Label: ({
+ children,
+ ...props
+ }: React.LabelHTMLAttributes) => (
+ {children}
+ ),
+ Badge: ({
+ children,
+ variant,
+ }: {
+ children: React.ReactNode;
+ variant?: string;
+ }) => {children} ,
+ Switch: ({
+ checked,
+ onCheckedChange,
+ }: {
+ checked: boolean;
+ onCheckedChange: (v: boolean) => void;
+ }) => (
+ onCheckedChange(!checked)}
+ >
+ {checked ? "On" : "Off"}
+
+ ),
+ Select: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ SelectContent: ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ ),
+ SelectItem: ({
+ children,
+ value,
+ }: {
+ children: React.ReactNode;
+ value: string;
+ }) => {children} ,
+ SelectTrigger: ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ ),
+ SelectValue: () => null,
+ EmptyState: ({
+ title,
+ description,
+ action,
+ }: {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+ action: React.ReactNode;
+ }) => (
+
+
{title}
+
{description}
+ {action}
+
+ ),
+ ConfirmDialog: ({
+ open,
+ onConfirm,
+ title,
+ }: {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ title: string;
+ description: string;
+ confirmText: string;
+ variant: string;
+ onConfirm: () => void;
+ }) =>
+ open ? (
+
+ ) : null,
+ Dialog: ({
+ children,
+ open,
+ }: {
+ children: React.ReactNode;
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ }) => (open ? {children}
: null),
+ DialogContent: ({
+ children,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ }) => {children}
,
+ DialogHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogDescription: ({
+ children,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ }) => {children}
,
+ DialogFooter: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ PasswordInput: (props: React.InputHTMLAttributes) => (
+
+ ),
+}));
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("AuthenticationPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("shows loading spinner when fetching", async () => {
+ mockUseSsoProviders.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ });
+
+ const { default: Page } = await import("../page");
+ render( );
+
+ expect(screen.getByText("Authentication")).toBeInTheDocument();
+ expect(screen.queryByTestId("empty-state")).not.toBeInTheDocument();
+ });
+
+ it("shows empty state when no providers", async () => {
+ mockUseSsoProviders.mockReturnValue({
+ data: [],
+ isLoading: false,
+ });
+
+ const { default: Page } = await import("../page");
+ render( );
+
+ expect(screen.getByTestId("empty-state")).toBeInTheDocument();
+ expect(screen.getByText("No SSO providers")).toBeInTheDocument();
+ });
+
+ it("renders provider list when providers exist", async () => {
+ mockUseSsoProviders.mockReturnValue({
+ data: [
+ {
+ id: "sso-1",
+ name: "Company SSO",
+ protocol: "oidc",
+ issuer: "https://idp.example.com",
+ clientId: "c",
+ scopes: "openid",
+ claimMappings: null,
+ autoProvision: true,
+ defaultRole: "creator",
+ enforceSso: false,
+ enabled: true,
+ createdAt: "2026-01-01",
+ updatedAt: "2026-01-01",
+ },
+ ],
+ isLoading: false,
+ });
+
+ const { default: Page } = await import("../page");
+ render( );
+
+ expect(screen.getByText("Company SSO")).toBeInTheDocument();
+ expect(screen.getByText("https://idp.example.com")).toBeInTheDocument();
+ expect(screen.getByText("Enabled")).toBeInTheDocument();
+ expect(screen.getByText("creator")).toBeInTheDocument();
+ });
+
+ it("opens add dialog when Add Provider is clicked", async () => {
+ mockUseSsoProviders.mockReturnValue({ data: [], isLoading: false });
+
+ const { default: Page } = await import("../page");
+ render( );
+
+ const user = userEvent.setup();
+ const addButtons = screen.getAllByText("Add Provider");
+ await user.click(addButtons[0]);
+
+ expect(screen.getByText("Add SSO Provider")).toBeInTheDocument();
+ expect(
+ screen.getByPlaceholderText("https://idp.example.com"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows delete confirmation when trash icon clicked", async () => {
+ mockUseSsoProviders.mockReturnValue({
+ data: [
+ {
+ id: "sso-1",
+ name: "Test Provider",
+ protocol: "oidc",
+ issuer: "https://test.com",
+ clientId: "c",
+ scopes: "openid",
+ claimMappings: null,
+ autoProvision: true,
+ defaultRole: "creator",
+ enforceSso: false,
+ enabled: true,
+ createdAt: "2026-01-01",
+ updatedAt: "2026-01-01",
+ },
+ ],
+ isLoading: false,
+ });
+
+ const { default: Page } = await import("../page");
+ render( );
+
+ const user = userEvent.setup();
+ const deleteBtn = screen.getByLabelText("Delete Test Provider");
+ await user.click(deleteBtn);
+
+ expect(screen.getByTestId("confirm-dialog")).toBeInTheDocument();
+ expect(screen.getByText("Delete SSO Provider")).toBeInTheDocument();
+ });
+
+ it("shows Disabled badge for disabled providers", async () => {
+ mockUseSsoProviders.mockReturnValue({
+ data: [
+ {
+ id: "sso-2",
+ name: "Disabled Provider",
+ protocol: "oidc",
+ issuer: "https://disabled.com",
+ clientId: "c",
+ scopes: "openid",
+ claimMappings: null,
+ autoProvision: true,
+ defaultRole: "reader",
+ enforceSso: false,
+ enabled: false,
+ createdAt: "2026-01-01",
+ updatedAt: "2026-01-01",
+ },
+ ],
+ isLoading: false,
+ });
+
+ const { default: Page } = await import("../page");
+ render( );
+
+ expect(screen.getByText("Disabled")).toBeInTheDocument();
+ expect(screen.getByText("reader")).toBeInTheDocument();
+ });
+});
diff --git a/app/src/app/(dashboard)/settings/authentication/page.tsx b/app/src/app/(dashboard)/settings/authentication/page.tsx
new file mode 100644
index 000000000..7ff08336f
--- /dev/null
+++ b/app/src/app/(dashboard)/settings/authentication/page.tsx
@@ -0,0 +1,474 @@
+"use client";
+
+import { useState } from "react";
+import {
+ Plus,
+ Trash2,
+ Shield,
+ Globe,
+ ToggleLeft,
+ ToggleRight,
+} from "lucide-react";
+import {
+ PageHeader,
+ Button,
+ Input,
+ Label,
+ Badge,
+ Switch,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ EmptyState,
+ ConfirmDialog,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+ PasswordInput,
+} from "@neoboard/components";
+import {
+ useSsoProviders,
+ useCreateSsoProvider,
+ useDeleteSsoProvider,
+} from "@/hooks/use-sso-providers";
+import type {
+ SsoProviderListItem,
+ CreateSsoProviderInput,
+} from "@/hooks/use-sso-providers";
+
+// ---------------------------------------------------------------------------
+// Add Provider Dialog
+// ---------------------------------------------------------------------------
+
+const EMPTY_FORM: CreateSsoProviderInput = {
+ name: "",
+ issuer: "",
+ clientId: "",
+ clientSecret: "",
+ scopes: "openid profile email",
+ autoProvision: true,
+ defaultRole: "creator",
+ enforceSso: false,
+};
+
+function AddProviderDialog({
+ open,
+ onClose,
+}: Readonly<{
+ open: boolean;
+ onClose: () => void;
+}>) {
+ const [form, setForm] = useState(EMPTY_FORM);
+ const [claimKey, setClaimKey] = useState("");
+ const [adminValue, setAdminValue] = useState("");
+ const [creatorValue, setCreatorValue] = useState("");
+ const [readerValue, setReaderValue] = useState("");
+ const createMutation = useCreateSsoProvider();
+
+ const handleCreate = async () => {
+ const claimMappings = claimKey.trim()
+ ? {
+ claimKey: claimKey.trim(),
+ ...(adminValue.trim() && { adminValue: adminValue.trim() }),
+ ...(creatorValue.trim() && { creatorValue: creatorValue.trim() }),
+ ...(readerValue.trim() && { readerValue: readerValue.trim() }),
+ }
+ : undefined;
+
+ await createMutation.mutateAsync({ ...form, claimMappings });
+ handleClose();
+ };
+
+ const handleClose = () => {
+ setForm(EMPTY_FORM);
+ setClaimKey("");
+ setAdminValue("");
+ setCreatorValue("");
+ setReaderValue("");
+ createMutation.reset();
+ onClose();
+ };
+
+ const update = (field: keyof CreateSsoProviderInput, value: unknown) =>
+ setForm((prev) => ({ ...prev, [field]: value }));
+
+ const isValid =
+ form.name.trim() &&
+ form.issuer.trim() &&
+ form.clientId.trim() &&
+ form.clientSecret.trim();
+
+ return (
+ !o && handleClose()}>
+
+
+ Add SSO Provider
+
+ Configure an OIDC provider for single sign-on authentication.
+
+
+
+
+ {/* Provider Details */}
+
+
+ Display Name *
+
+ update("name", e.target.value)}
+ />
+
+
+
+
+ Issuer URL *
+
+
update("issuer", e.target.value)}
+ />
+
+ The OIDC discovery endpoint will be resolved from this URL.
+
+
+
+
+
+
+ Client ID *
+
+ update("clientId", e.target.value)}
+ />
+
+
+
+ Client Secret *
+
+
update("clientSecret", e.target.value)}
+ />
+
+
+
+
+ Scopes
+ update("scopes", e.target.value)}
+ />
+
+
+ {/* Claim Mapping */}
+
+
Role Claim Mapping
+
+ Map an IdP claim to NeoBoard roles. Leave empty to use the default
+ role for all SSO users.
+
+
+
+ IdP Claim Key
+ setClaimKey(e.target.value)}
+ />
+
+
+ {claimKey.trim() && (
+
+ )}
+
+
+ {/* Provisioning Options */}
+
+
Provisioning
+
+
+
+
Auto-provision new users
+
+ Automatically create accounts for new SSO users.
+
+
+
update("autoProvision", checked)}
+ />
+
+
+
+
+
Default role
+
+ Role assigned when no claim mapping matches.
+
+
+
update("defaultRole", v)}
+ >
+
+
+
+
+ Admin
+ Creator
+ Reader
+
+
+
+
+
+
+
Enforce SSO
+
+ Disable password login for non-admin users.
+
+
+
update("enforceSso", checked)}
+ />
+
+
+
+ {createMutation.error && (
+
+ {createMutation.error.message}
+
+ )}
+
+
+
+ Cancel
+
+
+ {createMutation.isPending ? "Saving..." : "Add Provider"}
+
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Provider Row
+// ---------------------------------------------------------------------------
+
+function ProviderRow({
+ provider,
+ onDelete,
+}: Readonly<{
+ provider: SsoProviderListItem;
+ onDelete: (id: string) => void;
+}>) {
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ return (
+
+
+
+
+ {provider.name}
+
+
+
+ {provider.issuer}
+
+
+
+ {provider.enabled ? "Enabled" : "Disabled"}
+
+
+
+ {provider.defaultRole}
+
+
+ {provider.enforceSso ? (
+
+ ) : (
+
+ )}
+
+
+ setConfirmOpen(true)}
+ aria-label={"Delete " + provider.name}
+ >
+
+
+ {
+ onDelete(provider.id);
+ setConfirmOpen(false);
+ }}
+ />
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Page
+// ---------------------------------------------------------------------------
+
+export default function AuthenticationPage() {
+ const [createOpen, setCreateOpen] = useState(false);
+ const { data: providers = [], isLoading } = useSsoProviders();
+ const deleteMutation = useDeleteSsoProvider();
+
+ const handleDelete = (id: string) => {
+ deleteMutation.mutate(id);
+ };
+
+ return (
+
+
setCreateOpen(true)}>
+
+ Add Provider
+
+ }
+ />
+
+ {isLoading && (
+
+ )}
+
+ {!isLoading && providers.length === 0 && (
+ }
+ title="No SSO providers"
+ description="Add an OIDC provider to enable single sign-on for your organization."
+ action={
+ setCreateOpen(true)}>
+
+ Add Provider
+
+ }
+ />
+ )}
+
+ {!isLoading && providers.length > 0 && (
+
+
+
+
+
+ Provider
+
+
+ Issuer
+
+
+ Status
+
+
+ Default Role
+
+
+ SSO Enforced
+
+
+
+
+
+ {providers.map((provider) => (
+
+ ))}
+
+
+
+ )}
+
+ setCreateOpen(false)}
+ />
+
+ );
+}
diff --git a/app/src/app/(dashboard)/settings/layout.tsx b/app/src/app/(dashboard)/settings/layout.tsx
new file mode 100644
index 000000000..a2d8dd3f2
--- /dev/null
+++ b/app/src/app/(dashboard)/settings/layout.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useRouter, usePathname } from "next/navigation";
+import { User, KeyRound, Shield } from "lucide-react";
+
+const tabs = [
+ { href: "/settings/profile", label: "Profile", icon: User },
+ { href: "/settings/api-keys", label: "API Keys", icon: KeyRound },
+ { href: "/settings/authentication", label: "Authentication", icon: Shield },
+];
+
+export default function SettingsLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const router = useRouter();
+ const pathname = usePathname();
+
+ return (
+
+
+
+ {tabs.map(({ href, label, icon: Icon }) => {
+ const active = pathname === href;
+ return (
+ router.push(href)}
+ className={`flex items-center gap-2 border-b-2 px-1 py-3 text-sm font-medium transition-colors ${
+ active
+ ? "border-primary text-foreground"
+ : "border-transparent text-muted-foreground hover:text-foreground"
+ }`}
+ >
+
+ {label}
+
+ );
+ })}
+
+
+ {children}
+
+ );
+}
diff --git a/app/src/app/(dashboard)/settings/page.tsx b/app/src/app/(dashboard)/settings/page.tsx
new file mode 100644
index 000000000..cce8c29b4
--- /dev/null
+++ b/app/src/app/(dashboard)/settings/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function SettingsPage() {
+ redirect("/settings/profile");
+}
diff --git a/app/src/app/(dashboard)/settings/profile/page.tsx b/app/src/app/(dashboard)/settings/profile/page.tsx
new file mode 100644
index 000000000..9a5b11664
--- /dev/null
+++ b/app/src/app/(dashboard)/settings/profile/page.tsx
@@ -0,0 +1,285 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useSession } from "next-auth/react";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Input,
+ Label,
+ Badge,
+ Alert,
+ AlertDescription,
+} from "@neoboard/components";
+import { LoadingButton, PasswordInput } from "@neoboard/components";
+import { useToast } from "@neoboard/components";
+
+interface UserProfile {
+ id: string;
+ name: string | null;
+ email: string | null;
+ role: string;
+ canWrite: boolean;
+ createdAt: string;
+}
+
+export default function ProfilePage() {
+ const { update: updateSession } = useSession();
+ const { toast } = useToast();
+
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ // Name form
+ const [name, setName] = useState("");
+ const [savingName, setSavingName] = useState(false);
+
+ // Password form
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [savingPassword, setSavingPassword] = useState(false);
+ const [passwordError, setPasswordError] = useState("");
+ const [passwordSuccess, setPasswordSuccess] = useState(false);
+
+ useEffect(() => {
+ fetch("/api/users/me")
+ .then((r) => r.json())
+ .then((body) => {
+ if (body.data) {
+ setProfile(body.data);
+ setName(body.data.name ?? "");
+ }
+ })
+ .finally(() => setLoading(false));
+ }, []);
+
+ async function handleSaveName(e: React.FormEvent) {
+ e.preventDefault();
+ setSavingName(true);
+ try {
+ const res = await fetch("/api/users/me", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name }),
+ });
+ if (!res.ok) {
+ const body = await res.json();
+ toast({
+ title: "Failed to update name",
+ description: body.error ?? "Something went wrong.",
+ variant: "destructive",
+ });
+ } else {
+ setProfile((p) => (p ? { ...p, name } : p));
+ await updateSession();
+ toast({ title: "Name updated" });
+ }
+ } catch {
+ toast({
+ title: "Failed to update name",
+ description: "Something went wrong.",
+ variant: "destructive",
+ });
+ } finally {
+ setSavingName(false);
+ }
+ }
+
+ async function handleChangePassword(e: React.FormEvent) {
+ e.preventDefault();
+ setPasswordError("");
+ setPasswordSuccess(false);
+
+ if (newPassword !== confirmPassword) {
+ setPasswordError("New passwords do not match");
+ return;
+ }
+
+ setSavingPassword(true);
+ try {
+ const res = await fetch("/api/users/me/password", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ currentPassword, newPassword }),
+ });
+ if (!res.ok) {
+ const body = await res.json();
+ setPasswordError(body.error ?? "Failed to change password");
+ } else {
+ setCurrentPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ setPasswordSuccess(true);
+ toast({ title: "Password changed" });
+ }
+ } catch {
+ setPasswordError("Something went wrong.");
+ } finally {
+ setSavingPassword(false);
+ }
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Account Info */}
+
+
+ Account
+ Your account details
+
+
+
+
+
Email
+
{profile?.email ?? "—"}
+
+
+
Role
+
+
+ {profile?.role}
+
+
+
+
+
Write Access
+
{profile?.canWrite ? "Yes" : "No"}
+
+
+
Member Since
+
+ {profile?.createdAt
+ ? new Date(profile.createdAt).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })
+ : "—"}
+
+
+
+
+
+
+ {/* Name */}
+
+
+ Display Name
+
+ This is how your name appears across NeoBoard.
+
+
+
+
+
+
+
+ {/* Password */}
+
+
+ Change Password
+
+ Update your password. You will need your current password.
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/app/(dashboard)/users/page.tsx b/app/src/app/(dashboard)/users/page.tsx
new file mode 100644
index 000000000..6ca04dab3
--- /dev/null
+++ b/app/src/app/(dashboard)/users/page.tsx
@@ -0,0 +1,606 @@
+"use client";
+
+import { useState, useMemo, useCallback } from "react";
+import { useSession } from "next-auth/react";
+import type { Session } from "next-auth";
+import { Users as UsersIcon, Plus, MoreVertical, KeyRound } from "lucide-react";
+import {
+ useUsers,
+ useCreateUser,
+ useDeleteUser,
+ useUpdateUserRole,
+ useUpdateUserCanWrite,
+ useResetPassword,
+} from "@/hooks/use-users";
+import type { UserListItem } from "@/hooks/use-users";
+import type { UserRole } from "@/lib/db/schema";
+import {
+ Button,
+ Input,
+ Label,
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Badge,
+ Switch,
+ Checkbox,
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@neoboard/components";
+import {
+ PageHeader,
+ EmptyState,
+ LoadingButton,
+ LoadingOverlay,
+ ConfirmDialog,
+ DataGrid,
+ PasswordInput,
+ CopyButton,
+} from "@neoboard/components";
+import { useToast } from "@neoboard/components";
+import type { ColumnDef } from "@tanstack/react-table";
+
+const ROLE_VARIANTS: Record<
+ UserRole,
+ "default" | "secondary" | "destructive" | "outline"
+> = {
+ admin: "destructive",
+ creator: "default",
+ reader: "secondary",
+};
+
+type CanWriteCellProps = Readonly<{
+ id: string;
+ role: UserRole;
+ canWrite: boolean;
+ isSelf: boolean;
+ isAdmin: boolean;
+ onToggle: (id: string, checked: boolean) => void;
+}>;
+
+function CanWriteCell({
+ id,
+ role,
+ canWrite,
+ isSelf,
+ isAdmin,
+ onToggle,
+}: CanWriteCellProps) {
+ // Admins always write; readers never write; others use DB value
+ const effectiveCanWrite =
+ role === "admin" ? true : role === "reader" ? false : canWrite;
+ if (!isAdmin) {
+ return (
+
+ {effectiveCanWrite ? "Yes" : "No"}
+
+ );
+ }
+
+ // Disable toggle for self, admins (always on), and readers (always off)
+ const disabled = isSelf || role !== "creator";
+ const toggle = (
+ onToggle(id, checked)}
+ />
+ );
+
+ if (disabled) {
+ return (
+
+
+
+ {toggle}
+
+
+
+ {isSelf
+ ? "You cannot change your own write permission"
+ : "Readers cannot execute write queries"}
+
+
+ );
+ }
+
+ return toggle;
+}
+
+export default function UsersPage() {
+ const { data: session } = useSession();
+ type SessionUser = NonNullable & {
+ id?: string;
+ role?: UserRole;
+ tenantId?: string;
+ };
+ const sessionUser = session?.user as SessionUser | undefined;
+ const systemRole = (sessionUser?.role ?? "creator") as UserRole;
+ const isAdmin = systemRole === "admin";
+ const currentUserId: string | undefined = sessionUser?.id;
+
+ const { toast } = useToast();
+ const { data: users, isLoading, error } = useUsers();
+ const createUser = useCreateUser();
+ const deleteUser = useDeleteUser();
+ const updateRole = useUpdateUserRole();
+ const updateCanWrite = useUpdateUserCanWrite();
+
+ const resetPassword = useResetPassword();
+
+ const [showCreate, setShowCreate] = useState(false);
+ const [form, setForm] = useState<{
+ name: string;
+ email: string;
+ password: string;
+ role: UserRole;
+ forcePasswordChange: boolean;
+ }>({
+ name: "",
+ email: "",
+ password: "",
+ role: "creator",
+ forcePasswordChange: false,
+ });
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [createError, setCreateError] = useState(null);
+ const [tempPasswordData, setTempPasswordData] = useState<{
+ userName: string;
+ password: string;
+ } | null>(null);
+
+ const handleRoleUpdate = useCallback(
+ (id: string, val: string, displayName: string) => {
+ updateRole.mutate(
+ { id, role: val as UserRole },
+ {
+ onSuccess: () =>
+ toast({
+ title: "Role updated",
+ description: `${displayName} is now a${val === "admin" ? "n" : ""} ${val}.`,
+ }),
+ onError: (err) =>
+ toast({
+ title: "Failed to update role",
+ description:
+ err instanceof Error ? err.message : "Something went wrong.",
+ variant: "destructive",
+ }),
+ },
+ );
+ },
+ [updateRole, toast],
+ );
+
+ const handleCanWriteToggle = useCallback(
+ (id: string, checked: boolean, displayName: string) => {
+ updateCanWrite.mutate(
+ { id, canWrite: checked },
+ {
+ onSuccess: () =>
+ toast({
+ title: "Write permission updated",
+ description: `${displayName} can ${checked ? "now" : "no longer"} execute write queries.`,
+ }),
+ onError: (err) =>
+ toast({
+ title: "Failed to update write permission",
+ description:
+ err instanceof Error ? err.message : "Something went wrong.",
+ variant: "destructive",
+ }),
+ },
+ );
+ },
+ [updateCanWrite, toast],
+ );
+
+ const handleForcePasswordChange = useCallback(
+ async (user: UserListItem) => {
+ try {
+ const result = await resetPassword.mutateAsync({
+ id: user.id,
+ generatePassword: true,
+ forcePasswordChange: true,
+ });
+ if (result.generatedPassword) {
+ setTempPasswordData({
+ userName: user.name ?? user.email ?? "User",
+ password: result.generatedPassword,
+ });
+ }
+ toast({
+ title: "Password reset",
+ description: `${user.name ?? user.email} must change their password on next login.`,
+ });
+ } catch (err) {
+ toast({
+ title: "Failed to reset password",
+ description:
+ err instanceof Error ? err.message : "Something went wrong.",
+ variant: "destructive",
+ });
+ }
+ },
+ [resetPassword, toast],
+ );
+
+ const columns = useMemo(
+ (): ColumnDef[] => [
+ { accessorKey: "name", header: "Name" },
+ { accessorKey: "email", header: "Email" },
+ {
+ accessorKey: "role",
+ header: "Role",
+ cell: ({ row }) => {
+ const r = row.original.role;
+ const isSelf = row.original.id === currentUserId;
+ const displayName = row.original.name ?? row.original.email ?? "User";
+
+ if (!isAdmin) {
+ return (
+
+ {r}
+
+ );
+ }
+
+ if (isSelf) {
+ return (
+
+
+
+
+ {r}
+
+
+
+ You cannot change your own role
+
+ );
+ }
+
+ return (
+
+ handleRoleUpdate(row.original.id, val, displayName)
+ }
+ >
+
+
+
+
+ Admin
+ Creator
+ Reader
+
+
+ );
+ },
+ },
+ {
+ accessorKey: "canWrite",
+ header: "Write",
+ cell: ({ row }) => (
+
+ handleCanWriteToggle(
+ id,
+ checked,
+ row.original.name ?? row.original.email ?? "User",
+ )
+ }
+ />
+ ),
+ },
+ {
+ accessorKey: "createdAt",
+ header: "Created",
+ cell: ({ getValue }) => {
+ const v = getValue() as string;
+ return v ? new Date(v).toLocaleDateString() : "—";
+ },
+ },
+ {
+ id: "actions",
+ header: "",
+ cell: ({ row }) => {
+ const isSelf = row.original.id === currentUserId;
+ if (!isAdmin) return null;
+ return (
+
+
+
+
+ User actions
+
+
+
+ handleForcePasswordChange(row.original)}
+ >
+
+ Require Password Change
+
+
+ !isSelf && setDeleteTarget(row.original.id)}
+ >
+ Delete
+
+
+
+ );
+ },
+ },
+ ],
+ [
+ isAdmin,
+ currentUserId,
+ handleRoleUpdate,
+ handleCanWriteToggle,
+ handleForcePasswordChange,
+ ],
+ );
+
+ async function handleCreate(e: React.FormEvent) {
+ e.preventDefault();
+ setCreateError(null);
+ try {
+ const created = await createUser.mutateAsync(form);
+ setForm({
+ name: "",
+ email: "",
+ password: "",
+ role: "creator",
+ forcePasswordChange: false,
+ });
+ setShowCreate(false);
+ toast({
+ title: "User created",
+ description: `${created.name ?? created.email} has been added as a ${created.role}.`,
+ });
+ } catch (err) {
+ setCreateError(
+ err instanceof Error ? err.message : "Failed to create user",
+ );
+ }
+ }
+
+ return (
+
+
setShowCreate(true)}>
+
+ Create User
+
+ }
+ />
+
+
+
+
+
+
+
+ {
+ if (!open) setDeleteTarget(null);
+ }}
+ title="Delete User"
+ description="This will permanently delete this user and all their data."
+ confirmText="Delete"
+ variant="destructive"
+ onConfirm={() => {
+ if (deleteTarget) {
+ deleteUser.mutate(deleteTarget, {
+ onSuccess: () =>
+ toast({
+ title: "User deleted",
+ description: "The user has been removed.",
+ }),
+ onError: (err) =>
+ toast({
+ title: "Failed to delete user",
+ description:
+ err instanceof Error
+ ? err.message
+ : "Something went wrong.",
+ variant: "destructive",
+ }),
+ });
+ setDeleteTarget(null);
+ }
+ }}
+ />
+
+ {
+ if (!open) setTempPasswordData(null);
+ }}
+ >
+
+
+ Temporary Password
+
+
+
+ A temporary password has been generated for{" "}
+
+ {tempPasswordData?.userName}
+
+ . They will be required to change it on their next login.
+
+
+
+ {tempPasswordData?.password}
+
+
+
+
+ Make sure to copy this password now. It cannot be retrieved later.
+
+
+
+ setTempPasswordData(null)}>Done
+
+
+
+
+
+
+ {error instanceof Error && error.message === "Forbidden" ? (
+ }
+ title="Admin access required"
+ description="Only administrators can manage users."
+ />
+ ) : !users?.length ? (
+ }
+ title="No users found"
+ description="Create your first user to get started."
+ action={
+ setShowCreate(true)}>
+
+ Create User
+
+ }
+ />
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/app/src/app/(dashboard)/widget-lab/page.tsx b/app/src/app/(dashboard)/widget-lab/page.tsx
new file mode 100644
index 000000000..d24473d3d
--- /dev/null
+++ b/app/src/app/(dashboard)/widget-lab/page.tsx
@@ -0,0 +1,503 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import { useRouter } from "next/navigation";
+import {
+ FlaskConical,
+ Trash2,
+ Pencil,
+ Plus,
+ LayoutDashboard,
+ Copy,
+ Play,
+} from "lucide-react";
+import { useSession } from "next-auth/react";
+import {
+ useWidgetTemplates,
+ useDeleteWidgetTemplate,
+ useCreateWidgetTemplate,
+} from "@/hooks/use-widget-templates";
+import { useConnections } from "@/hooks/use-connections";
+import { getChartConfig } from "@/lib/plugin/chart-helpers";
+import { DashboardPickerDialog } from "@/components/dashboard-picker-dialog";
+import {
+ PageHeader,
+ EmptyState,
+ LoadingOverlay,
+ Badge,
+ Button,
+ Input,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ ConfirmDialog,
+ CodePreview,
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+ useToast,
+} from "@neoboard/components";
+import type { WidgetTemplate } from "@/lib/db/schema";
+import {
+ type ConnectorType,
+ CONNECTOR_TYPES,
+ CONNECTOR_LABELS,
+ CONNECTOR_LANGUAGES,
+} from "@/lib/connector/connector-types";
+import { WidgetEditorModal } from "@/components/widget-editor-modal";
+
+function TemplateCard({
+ template,
+ canEdit,
+ canDelete,
+ onEdit,
+ onDelete,
+ onDuplicate,
+ onTestQuery,
+ testQueryLoading,
+ onUseInDashboard,
+}: {
+ readonly template: WidgetTemplate;
+ readonly canEdit: boolean;
+ readonly canDelete: boolean;
+ readonly onEdit: () => void;
+ readonly onDelete: () => void;
+ readonly onDuplicate: () => void;
+ readonly onTestQuery: () => void;
+ readonly testQueryLoading: boolean;
+ readonly onUseInDashboard: () => void;
+}) {
+ const chartLabel =
+ getChartConfig(template.chartType)?.label ?? template.chartType;
+
+ return (
+
+
+
+
+
{template.name}
+ {template.description && (
+
+ {template.description}
+
+ )}
+
+
+
+
+
+
+
+
+ Use in Dashboard
+
+
+
+
+
+
+
+ Duplicate
+
+ {template.query && template.connectionId && (
+
+
+
+
+
+
+ Test query
+
+ )}
+ {canEdit && (
+
+
+
+
+
+
+ Edit
+
+ )}
+ {canDelete && (
+
+
+
+
+
+
+ Delete
+
+ )}
+
+
+
+
+
+
+
+
+ {chartLabel}
+
+
+ {template.connectorType}
+
+ {(template.tags ?? []).map((tag) => (
+
+ {tag}
+
+ ))}
+
+ {template.createdAt && (
+
+ {new Date(template.createdAt).toLocaleDateString()}
+
+ )}
+
+
+
+ );
+}
+
+export default function WidgetLabPage() {
+ const router = useRouter();
+ const { data: session } = useSession();
+ const userId = session?.user?.id ?? "";
+ const role = session?.user?.role ?? "creator";
+
+ const { toast } = useToast();
+ const { data: templates, isLoading } = useWidgetTemplates();
+ const deleteTemplate = useDeleteWidgetTemplate();
+ const createTemplate = useCreateWidgetTemplate();
+ const { data: connections = [] } = useConnections();
+ const [testingTemplateId, setTestingTemplateId] = useState(
+ null,
+ );
+
+ const [search, setSearch] = useState("");
+ const [filterChartType, setFilterChartType] = useState("all");
+ const [filterConnector, setFilterConnector] = useState("all");
+ const [filterTag, setFilterTag] = useState("all");
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [useTarget, setUseTarget] = useState(null);
+
+ // Editor modal state
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [editingTemplate, setEditingTemplate] = useState<
+ WidgetTemplate | undefined
+ >();
+ const editorMode = editingTemplate
+ ? ("lab-edit" as const)
+ : ("lab-create" as const);
+
+ const chartTypes = useMemo(() => {
+ if (!templates) return [];
+ return [...new Set(templates.map((t) => t.chartType))].sort((a, b) =>
+ a.localeCompare(b),
+ );
+ }, [templates]);
+
+ const allTags = useMemo(() => {
+ if (!templates) return [];
+ const tags = templates.flatMap((t) => t.tags ?? []);
+ return [...new Set(tags)].sort((a, b) => a.localeCompare(b));
+ }, [templates]);
+
+ const filtered = useMemo(() => {
+ if (!templates) return [];
+ return templates.filter((t) => {
+ if (
+ search &&
+ !t.name.toLowerCase().includes(search.toLowerCase()) &&
+ !(t.description ?? "").toLowerCase().includes(search.toLowerCase())
+ ) {
+ return false;
+ }
+ if (filterChartType !== "all" && t.chartType !== filterChartType)
+ return false;
+ if (filterConnector !== "all" && t.connectorType !== filterConnector)
+ return false;
+ if (filterTag !== "all" && !(t.tags ?? []).includes(filterTag))
+ return false;
+ return true;
+ });
+ }, [templates, search, filterChartType, filterConnector, filterTag]);
+
+ function canEditOrDelete(template: WidgetTemplate) {
+ return role === "admin" || template.createdBy === userId;
+ }
+
+ function handleCreate() {
+ setEditingTemplate(undefined);
+ setEditorOpen(true);
+ }
+
+ function handleEdit(template: WidgetTemplate) {
+ setEditingTemplate(template);
+ setEditorOpen(true);
+ }
+
+ function handleDuplicate(template: WidgetTemplate) {
+ const baseName = template.name.replace(/\s*\(copy(?:\s\d+)?\)$/, "");
+ createTemplate.mutate(
+ {
+ name: `${baseName} (copy)`,
+ description: template.description ?? undefined,
+ tags: template.tags ?? undefined,
+ chartType: template.chartType,
+ connectorType: template.connectorType as ConnectorType,
+ connectionId: template.connectionId ?? undefined,
+ query: template.query,
+ settings: (template.settings as Record) ?? undefined,
+ },
+ {
+ onSuccess: () =>
+ toast({
+ title: "Template duplicated",
+ description: `"${baseName} (copy)" has been created.`,
+ }),
+ onError: (err) =>
+ toast({
+ title: "Failed to duplicate template",
+ description:
+ err instanceof Error ? err.message : "Something went wrong.",
+ variant: "destructive",
+ }),
+ },
+ );
+ }
+
+ async function handleTestQuery(template: WidgetTemplate) {
+ if (!template.connectionId || !template.query) return;
+ setTestingTemplateId(template.id);
+ try {
+ const res = await fetch("/api/query", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ connectionId: template.connectionId,
+ query: template.query,
+ params: template.params ?? {},
+ }),
+ signal: AbortSignal.timeout(30_000),
+ });
+ if (res.ok) {
+ toast({
+ title: "Query executed successfully",
+ description: `Template "${template.name}" query returned a valid response.`,
+ });
+ } else {
+ const err = await res.json();
+ toast({
+ title: "Query failed",
+ description: err.error?.message ?? res.statusText,
+ variant: "destructive",
+ });
+ }
+ } catch (e) {
+ const isTimeout = e instanceof DOMException && e.name === "TimeoutError";
+ toast({
+ title: isTimeout ? "Query timed out" : "Query failed",
+ description: isTimeout
+ ? "The query did not respond within 30 seconds."
+ : e instanceof Error
+ ? e.message
+ : "Unknown error",
+ variant: "destructive",
+ });
+ } finally {
+ setTestingTemplateId(null);
+ }
+ }
+
+ return (
+
+
+
+ New Template
+
+ }
+ />
+
+
+
+ setSearch(e.target.value)}
+ className="max-w-xs"
+ />
+
+
+
+
+
+
+ All chart types
+ {chartTypes.map((ct) => (
+
+ {getChartConfig(ct)?.label ?? ct}
+
+ ))}
+
+
+
+
+
+
+
+
+ All connectors
+ {CONNECTOR_TYPES.map((ct) => (
+
+ {CONNECTOR_LABELS[ct]}
+
+ ))}
+
+
+
+ {allTags.length > 0 && (
+
+
+
+
+
+ All tags
+ {allTags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ )}
+
+
+
+ {!isLoading &&
+ filtered.length === 0 &&
+ (templates?.length === 0 ? (
+ }
+ title="No templates yet"
+ description='Create a new template or save a widget from any dashboard using the "Save to Widget Lab" action.'
+ />
+ ) : (
+ }
+ title="No templates match your filters"
+ description="Try adjusting the search or filter options."
+ />
+ ))}
+ {!isLoading && filtered.length > 0 && (
+
+ {filtered.map((template) => (
+ handleEdit(template)}
+ onDelete={() => setDeleteTarget(template.id)}
+ onDuplicate={() => handleDuplicate(template)}
+ onTestQuery={() => handleTestQuery(template)}
+ testQueryLoading={testingTemplateId === template.id}
+ onUseInDashboard={() => setUseTarget(template.id)}
+ />
+ ))}
+
+ )}
+
+
+
+ {
+ if (!open) setDeleteTarget(null);
+ }}
+ title="Delete Template"
+ description="This will permanently delete this template. It will not affect existing dashboard widgets."
+ confirmText="Delete"
+ variant="destructive"
+ onConfirm={() => {
+ if (deleteTarget) {
+ deleteTemplate.mutate(deleteTarget);
+ setDeleteTarget(null);
+ }
+ }}
+ />
+
+ {
+ /* not used in lab mode */
+ }}
+ onLabSaved={() => setEditorOpen(false)}
+ />
+
+ {
+ if (!open) setUseTarget(null);
+ }}
+ onSelect={(dashboardId) => {
+ router.push(`/${dashboardId}/edit?templateId=${useTarget}`);
+ }}
+ />
+
+ );
+}
diff --git a/app/src/app/api/admin/rotate-key/__tests__/route.test.ts b/app/src/app/api/admin/rotate-key/__tests__/route.test.ts
new file mode 100644
index 000000000..7df57bf2d
--- /dev/null
+++ b/app/src/app/api/admin/rotate-key/__tests__/route.test.ts
@@ -0,0 +1,221 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireSession = vi.fn<
+ () => Promise<{
+ userId: string;
+ role: string;
+ canWrite: boolean;
+ tenantId: string;
+ }>
+>();
+
+vi.mock("@/lib/auth/session", () => ({
+ requireSession: mockRequireSession,
+}));
+
+// Mock crypto module — we test the rotation logic at the route level,
+// not the actual AES encryption (that's covered by crypto tests).
+const mockDecrypt = vi.fn<(s: string) => string>();
+const mockEncrypt = vi.fn<(s: string) => string>();
+
+vi.mock("@/lib/crypto/crypto", () => ({
+ decrypt: (s: string) => mockDecrypt(s),
+ encrypt: (s: string) => mockEncrypt(s),
+}));
+
+// Build mock Drizzle chains
+function makeSelectChain(rows: unknown[]) {
+ const resolved = Promise.resolve(rows);
+ const c = Object.assign(resolved, {
+ from: () => c,
+ where: () => c,
+ });
+ return c;
+}
+
+function makeUpdateChain() {
+ const c = {
+ set: () => c,
+ where: () => Promise.resolve(),
+ };
+ return c;
+}
+
+const mockDb = {
+ select: vi.fn(),
+ update: vi.fn(),
+ transaction: vi.fn(),
+};
+
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+
+// Mock NextResponse
+vi.mock("next/server", () => ({
+ NextResponse: {
+ json: (body: unknown, init?: ResponseInit) => ({
+ _body: body,
+ status: init?.status ?? 200,
+ json: async () => body,
+ }),
+ },
+}));
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("POST /api/admin/rotate-key", () => {
+ const originalOldKey = process.env.ENCRYPTION_KEY_OLD;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: () => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+
+ // Re-mock after resetModules
+ vi.doMock("@/lib/auth/session", () => ({
+ requireSession: mockRequireSession,
+ }));
+ vi.doMock("@/lib/crypto/crypto", () => ({
+ decrypt: (s: string) => mockDecrypt(s),
+ encrypt: (s: string) => mockEncrypt(s),
+ }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("next/server", () => ({
+ NextResponse: {
+ json: (body: unknown, init?: ResponseInit) => ({
+ _body: body,
+ status: init?.status ?? 200,
+ json: async () => body,
+ }),
+ },
+ }));
+
+ const mod = await import("../route");
+ POST = mod.POST;
+ });
+
+ afterEach(() => {
+ if (originalOldKey !== undefined) {
+ process.env.ENCRYPTION_KEY_OLD = originalOldKey;
+ } else {
+ delete process.env.ENCRYPTION_KEY_OLD;
+ }
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new Error("Unauthorized"));
+ const res = await POST();
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 for non-admin users", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ const res = await POST();
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 400 when ENCRYPTION_KEY_OLD is not set", async () => {
+ delete process.env.ENCRYPTION_KEY_OLD;
+ mockRequireSession.mockResolvedValue({
+ userId: "admin-1",
+ role: "admin",
+ canWrite: true,
+ tenantId: "default",
+ });
+ const res = await POST();
+ expect(res.status).toBe(400);
+ expect(res._body.error.message).toContain("ENCRYPTION_KEY_OLD");
+ });
+
+ it("returns 200 and re-encrypts connections and SSO providers", async () => {
+ process.env.ENCRYPTION_KEY_OLD = "a".repeat(64);
+ mockRequireSession.mockResolvedValue({
+ userId: "admin-1",
+ role: "admin",
+ canWrite: true,
+ tenantId: "default",
+ });
+
+ const connectionRows = [
+ { id: "conn-1", configEncrypted: "old-cipher-1" },
+ { id: "conn-2", configEncrypted: "old-cipher-2" },
+ ];
+ const ssoRows = [
+ { id: "sso-1", clientSecretEncrypted: "old-sso-cipher-1" },
+ ];
+
+ // decrypt returns deterministic plaintext
+ mockDecrypt.mockImplementation((s: string) => `plain:${s}`);
+ // encrypt returns deterministic ciphertext
+ mockEncrypt.mockImplementation((s: string) => `new:${s}`);
+
+ // The route uses db.transaction, so simulate it by calling the callback
+ // with a mock tx that behaves like db.
+ const mockTx = {
+ select: vi.fn(),
+ update: vi.fn(),
+ };
+
+ // First select = connections, second = sso_providers
+ mockTx.select
+ .mockReturnValueOnce(makeSelectChain(connectionRows))
+ .mockReturnValueOnce(makeSelectChain(ssoRows));
+ mockTx.update.mockReturnValue(makeUpdateChain());
+
+ mockDb.transaction.mockImplementation(
+ async (cb: (tx: typeof mockTx) => Promise) => cb(mockTx),
+ );
+
+ const res = await POST();
+ expect(res.status).toBe(200);
+ expect(res._body.data.connections).toBe(2);
+ expect(res._body.data.ssoProviders).toBe(1);
+
+ // Verify decrypt was called for each row
+ expect(mockDecrypt).toHaveBeenCalledTimes(3);
+ // Verify encrypt was called for each row
+ expect(mockEncrypt).toHaveBeenCalledTimes(3);
+ // Verify update was called for each row
+ expect(mockTx.update).toHaveBeenCalledTimes(3);
+ });
+
+ it("returns 200 with zero counts when no records exist", async () => {
+ process.env.ENCRYPTION_KEY_OLD = "b".repeat(64);
+ mockRequireSession.mockResolvedValue({
+ userId: "admin-1",
+ role: "admin",
+ canWrite: true,
+ tenantId: "default",
+ });
+
+ const mockTx = {
+ select: vi.fn(),
+ update: vi.fn(),
+ };
+
+ mockTx.select
+ .mockReturnValueOnce(makeSelectChain([]))
+ .mockReturnValueOnce(makeSelectChain([]));
+
+ mockDb.transaction.mockImplementation(
+ async (cb: (tx: typeof mockTx) => Promise) => cb(mockTx),
+ );
+
+ const res = await POST();
+ expect(res.status).toBe(200);
+ expect(res._body.data.connections).toBe(0);
+ expect(res._body.data.ssoProviders).toBe(0);
+ });
+});
diff --git a/app/src/app/api/admin/rotate-key/route.ts b/app/src/app/api/admin/rotate-key/route.ts
new file mode 100644
index 000000000..9105400ae
--- /dev/null
+++ b/app/src/app/api/admin/rotate-key/route.ts
@@ -0,0 +1,78 @@
+import { eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { connections, ssoProviders } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { decrypt, encrypt } from "@/lib/crypto/crypto";
+import { apiSuccess } from "@/lib/api/api-response";
+import { badRequest, forbidden, handleRouteError } from "@/lib/api/api-utils";
+
+/**
+ * POST /api/admin/rotate-key
+ *
+ * Re-encrypts all stored credentials with the current ENCRYPTION_KEY.
+ * Requires ENCRYPTION_KEY_OLD to be set so that records encrypted with the
+ * previous key can be decrypted during the migration.
+ *
+ * Admin-only. Runs inside a transaction for atomicity.
+ */
+export async function POST() {
+ try {
+ const { role } = await requireSession();
+
+ if (role !== "admin") {
+ return forbidden();
+ }
+
+ if (!process.env.ENCRYPTION_KEY_OLD) {
+ return badRequest(
+ "ENCRYPTION_KEY_OLD must be set to rotate keys. " +
+ "Set it to the previous encryption key value before calling this endpoint.",
+ );
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // ── Re-encrypt connections ──────────────────────────────────────
+ const allConnections = await tx
+ .select({
+ id: connections.id,
+ configEncrypted: connections.configEncrypted,
+ })
+ .from(connections);
+
+ for (const conn of allConnections) {
+ const plaintext = decrypt(conn.configEncrypted);
+ const reEncrypted = encrypt(plaintext);
+ await tx
+ .update(connections)
+ .set({ configEncrypted: reEncrypted })
+ .where(eq(connections.id, conn.id));
+ }
+
+ // ── Re-encrypt SSO provider secrets ─────────────────────────────
+ const allSsoProviders = await tx
+ .select({
+ id: ssoProviders.id,
+ clientSecretEncrypted: ssoProviders.clientSecretEncrypted,
+ })
+ .from(ssoProviders);
+
+ for (const provider of allSsoProviders) {
+ const plaintext = decrypt(provider.clientSecretEncrypted);
+ const reEncrypted = encrypt(plaintext);
+ await tx
+ .update(ssoProviders)
+ .set({ clientSecretEncrypted: reEncrypted })
+ .where(eq(ssoProviders.id, provider.id));
+ }
+
+ return {
+ connections: allConnections.length,
+ ssoProviders: allSsoProviders.length,
+ };
+ });
+
+ return apiSuccess(result);
+ } catch (error) {
+ return handleRouteError(error, "Failed to rotate encryption keys");
+ }
+}
diff --git a/app/src/app/api/audit-logs/__tests__/route.test.ts b/app/src/app/api/audit-logs/__tests__/route.test.ts
new file mode 100644
index 000000000..1134c01e9
--- /dev/null
+++ b/app/src/app/api/audit-logs/__tests__/route.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+const mockRequireSession = vi.fn();
+const mockSelect = vi.fn();
+
+vi.mock("@/lib/auth/session", () => ({ requireSession: mockRequireSession }));
+vi.mock("@/lib/db", () => ({
+ db: { select: mockSelect },
+}));
+vi.mock("@/lib/db/schema", () => ({
+ auditLogs: {
+ tenantId: "tenant_id",
+ action: "action",
+ userId: "user_id",
+ resourceType: "resource_type",
+ createdAt: "created_at",
+ },
+}));
+vi.mock("next/server", () => nextResponseMockFactory());
+
+function makeRequest(params: Record = {}) {
+ const url = new URL("http://localhost/api/audit-logs");
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
+ return new Request(url.toString());
+}
+
+function drizzleSelectChain(rows: unknown[], count = rows.length) {
+ const chain = {
+ from: () => chain,
+ where: () => chain,
+ orderBy: () => chain,
+ limit: () => chain,
+ offset: () => Promise.resolve(rows),
+ then: (resolve: (v: unknown[]) => unknown) =>
+ Promise.resolve(rows).then(resolve),
+ };
+ // Second call returns count
+ const countChain = {
+ from: () => countChain,
+ where: () => countChain,
+ then: (resolve: (v: unknown[]) => unknown) =>
+ Promise.resolve([{ count }]).then(resolve),
+ };
+ let callCount = 0;
+ return () => {
+ callCount++;
+ return callCount === 1 ? chain : countChain;
+ };
+}
+
+describe("GET /api/audit-logs", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({
+ requireSession: mockRequireSession,
+ }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+
+ const selectFn = drizzleSelectChain(
+ [{ id: "log-1", action: "dashboard.create", userId: "user-1" }],
+ 1,
+ );
+ vi.doMock("@/lib/db", () => ({
+ db: { select: selectFn },
+ }));
+
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 403 for non-admin users", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "tenant-a",
+ role: "creator",
+ });
+ const res = await GET(makeRequest());
+ expect(res.status).toBe(403);
+ });
+
+ it("returns audit logs for admin", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "admin-1",
+ tenantId: "tenant-a",
+ role: "admin",
+ });
+ const res = await GET(makeRequest());
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(1);
+ expect(body.data[0].action).toBe("dashboard.create");
+ });
+
+ it("supports pagination parameters", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "admin-1",
+ tenantId: "tenant-a",
+ role: "admin",
+ });
+ const res = await GET(makeRequest({ page: "2", limit: "10" }));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.meta.offset).toBe(10);
+ expect(body.meta.limit).toBe(10);
+ });
+});
diff --git a/app/src/app/api/audit-logs/route.ts b/app/src/app/api/audit-logs/route.ts
new file mode 100644
index 000000000..4bfd8d41c
--- /dev/null
+++ b/app/src/app/api/audit-logs/route.ts
@@ -0,0 +1,65 @@
+import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { auditLogs } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { forbidden } from "@/lib/api/api-utils";
+import { apiList } from "@/lib/api/api-response";
+
+/**
+ * GET /api/audit-logs
+ *
+ * Returns paginated audit log entries (admin only).
+ * Supports filtering by action, userId, resourceType, and date range.
+ */
+export async function GET(request: Request) {
+ const session = await requireSession();
+ if (session.role !== "admin") return forbidden();
+
+ const url = new URL(request.url);
+ const page = Math.max(
+ 1,
+ Number.parseInt(url.searchParams.get("page") ?? "1", 10),
+ );
+ const limit = Math.min(
+ 100,
+ Math.max(1, Number.parseInt(url.searchParams.get("limit") ?? "50", 10)),
+ );
+ const offset = (page - 1) * limit;
+
+ const action = url.searchParams.get("action");
+ const userId = url.searchParams.get("userId");
+ const resourceType = url.searchParams.get("resourceType");
+ const from = url.searchParams.get("from");
+ const to = url.searchParams.get("to");
+
+ const conditions = [eq(auditLogs.tenantId, session.tenantId)];
+ if (action) conditions.push(eq(auditLogs.action, action));
+ if (userId) conditions.push(eq(auditLogs.userId, userId));
+ if (resourceType) conditions.push(eq(auditLogs.resourceType, resourceType));
+ if (from) conditions.push(gte(auditLogs.createdAt, new Date(from)));
+ if (to) conditions.push(lte(auditLogs.createdAt, new Date(to)));
+
+ const where = and(...conditions);
+
+ const [rows, countResult] = await Promise.all([
+ db
+ .select()
+ .from(auditLogs)
+ .where(where)
+ .orderBy(desc(auditLogs.createdAt))
+ .limit(limit)
+ .offset(offset),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(auditLogs)
+ .where(where),
+ ]);
+
+ const total = countResult[0]?.count ?? 0;
+
+ return apiList(rows, {
+ total,
+ limit,
+ offset,
+ });
+}
diff --git a/app/src/app/api/auth/[...nextauth]/route.ts b/app/src/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 000000000..4b7543dfe
--- /dev/null
+++ b/app/src/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1 @@
+export { GET, POST } from "@/lib/auth/config";
diff --git a/app/src/app/api/auth/bootstrap-status/__tests__/route.test.ts b/app/src/app/api/auth/bootstrap-status/__tests__/route.test.ts
new file mode 100644
index 000000000..0fc82a089
--- /dev/null
+++ b/app/src/app/api/auth/bootstrap-status/__tests__/route.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockAreUsersEmpty = vi.fn<() => Promise>();
+
+vi.mock("@/lib/auth/signup", () => ({ areUsersEmpty: mockAreUsersEmpty }));
+vi.mock("next/server", () => nextResponseMockFactory());
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("GET /api/auth/bootstrap-status", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: () => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns bootstrapRequired: true when no users exist", async () => {
+ mockAreUsersEmpty.mockResolvedValue(true);
+ const res = await GET();
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.bootstrapRequired).toBe(true);
+ expect(body.data.registrationEnabled).toBe(true);
+ });
+
+ it("returns bootstrapRequired: false when users exist", async () => {
+ mockAreUsersEmpty.mockResolvedValue(false);
+ const res = await GET();
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.bootstrapRequired).toBe(false);
+ expect(body.data.registrationEnabled).toBe(true);
+ });
+
+ it("returns registrationEnabled: false when REGISTRATION_ENABLED=false", async () => {
+ process.env.REGISTRATION_ENABLED = "false";
+ mockAreUsersEmpty.mockResolvedValue(false);
+ const res = await GET();
+ const body = await res.json();
+ expect(body.data.registrationEnabled).toBe(false);
+ delete process.env.REGISTRATION_ENABLED;
+ });
+
+ it("returns registrationEnabled: false when REGISTRATION_ENABLED=False (case-insensitive)", async () => {
+ process.env.REGISTRATION_ENABLED = "False";
+ mockAreUsersEmpty.mockResolvedValue(false);
+ vi.resetModules();
+ vi.doMock("@/lib/auth/signup", () => ({
+ areUsersEmpty: mockAreUsersEmpty,
+ }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ const mod = await import("../route");
+ const res = await mod.GET();
+ const body = await res.json();
+ expect(body.data.registrationEnabled).toBe(false);
+ delete process.env.REGISTRATION_ENABLED;
+ });
+
+ it("returns registrationEnabled: true when REGISTRATION_ENABLED is not set", async () => {
+ delete process.env.REGISTRATION_ENABLED;
+ mockAreUsersEmpty.mockResolvedValue(false);
+ const res = await GET();
+ const body = await res.json();
+ expect(body.data.registrationEnabled).toBe(true);
+ });
+
+ it("returns registrationEnabled: true when REGISTRATION_ENABLED=true", async () => {
+ process.env.REGISTRATION_ENABLED = "true";
+ mockAreUsersEmpty.mockResolvedValue(false);
+ vi.resetModules();
+ vi.doMock("@/lib/auth/signup", () => ({
+ areUsersEmpty: mockAreUsersEmpty,
+ }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ const mod = await import("../route");
+ const res = await mod.GET();
+ const body = await res.json();
+ expect(body.data.registrationEnabled).toBe(true);
+ delete process.env.REGISTRATION_ENABLED;
+ });
+
+ it("returns both bootstrapRequired and registrationEnabled together", async () => {
+ process.env.REGISTRATION_ENABLED = "false";
+ mockAreUsersEmpty.mockResolvedValue(true);
+ vi.resetModules();
+ vi.doMock("@/lib/auth/signup", () => ({
+ areUsersEmpty: mockAreUsersEmpty,
+ }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ const mod = await import("../route");
+ const res = await mod.GET();
+ const body = await res.json();
+ expect(body.data.bootstrapRequired).toBe(true);
+ expect(body.data.registrationEnabled).toBe(false);
+ delete process.env.REGISTRATION_ENABLED;
+ });
+});
diff --git a/app/src/app/api/auth/bootstrap-status/route.ts b/app/src/app/api/auth/bootstrap-status/route.ts
new file mode 100644
index 000000000..a282f5083
--- /dev/null
+++ b/app/src/app/api/auth/bootstrap-status/route.ts
@@ -0,0 +1,10 @@
+import { areUsersEmpty } from "@/lib/auth/signup";
+import { apiSuccess } from "@/lib/api/api-response";
+
+// Public route — no auth required. Returns only booleans, no user data.
+export async function GET() {
+ const bootstrapRequired = await areUsersEmpty();
+ const registrationEnabled =
+ process.env.REGISTRATION_ENABLED?.toLowerCase() !== "false";
+ return apiSuccess({ bootstrapRequired, registrationEnabled });
+}
diff --git a/app/src/app/api/auth/sso-providers/__tests__/route.test.ts b/app/src/app/api/auth/sso-providers/__tests__/route.test.ts
new file mode 100644
index 000000000..1c0645a2a
--- /dev/null
+++ b/app/src/app/api/auth/sso-providers/__tests__/route.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeSelectChain } from "@/__tests__/helpers/drizzle-mocks";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockDb = {
+ select: vi.fn(),
+};
+
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("next/server", () => nextResponseMockFactory());
+
+// ---------------------------------------------------------------------------
+// Tests — GET /api/auth/sso-providers (public, no auth required)
+// ---------------------------------------------------------------------------
+
+describe("GET /api/auth/sso-providers", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: () => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns empty array when no providers configured", async () => {
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await GET();
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual([]);
+ });
+
+ it("returns only id and name of enabled providers", async () => {
+ const rows = [
+ { id: "sso-1", name: "Company SSO" },
+ { id: "sso-2", name: "Google Workspace" },
+ ];
+ mockDb.select.mockReturnValue(makeSelectChain(rows));
+ const res = await GET();
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(2);
+ expect(body.data[0]).toEqual({ id: "sso-1", name: "Company SSO" });
+ // Must not leak secrets or internal config
+ expect(body.data[0]).not.toHaveProperty("clientId");
+ expect(body.data[0]).not.toHaveProperty("clientSecretEncrypted");
+ expect(body.data[0]).not.toHaveProperty("issuer");
+ });
+
+ it("does not require authentication", async () => {
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ // If this handler required auth, it would throw — it should not
+ const res = await GET();
+ expect(res.status).toBe(200);
+ });
+});
diff --git a/app/src/app/api/auth/sso-providers/route.ts b/app/src/app/api/auth/sso-providers/route.ts
new file mode 100644
index 000000000..1a9a5bde9
--- /dev/null
+++ b/app/src/app/api/auth/sso-providers/route.ts
@@ -0,0 +1,51 @@
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { ssoProviders } from "@/lib/db/schema";
+import { loadEnvSsoProvider } from "@/lib/auth/sso/env-provider";
+import { apiSuccess } from "@/lib/api/api-response";
+import { handleRouteError } from "@/lib/api/api-utils";
+
+/**
+ * Public endpoint — returns only id + name of enabled SSO providers.
+ * Merges the env-based provider (if configured) with DB-based providers.
+ * Used by the login page to render SSO buttons.
+ * No auth required (falls under /api/auth/ public prefix).
+ */
+export async function GET() {
+ try {
+ const tenantId = process.env.TENANT_ID ?? "default";
+
+ const rows = await db
+ .select({
+ id: ssoProviders.id,
+ name: ssoProviders.name,
+ enforceSso: ssoProviders.enforceSso,
+ })
+ .from(ssoProviders)
+ .where(
+ and(
+ eq(ssoProviders.tenantId, tenantId),
+ eq(ssoProviders.enabled, true),
+ ),
+ );
+
+ let enforceSso = rows.some((r) => r.enforceSso);
+ const providers: { id: string; name: string }[] = rows.map(
+ ({ id, name }) => ({ id, name }),
+ );
+
+ // Prepend env-based provider if configured (appears first on login page)
+ const envProvider = loadEnvSsoProvider();
+ if (envProvider) {
+ providers.unshift({
+ id: envProvider.id.replace("sso-", ""),
+ name: envProvider.name,
+ });
+ if (envProvider.metadata.enforceSso) enforceSso = true;
+ }
+
+ return apiSuccess(providers, 200, { enforceSso });
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
diff --git a/app/src/app/api/connections/[id]/__tests__/route.test.ts b/app/src/app/api/connections/[id]/__tests__/route.test.ts
new file mode 100644
index 000000000..1fa0b0dcc
--- /dev/null
+++ b/app/src/app/api/connections/[id]/__tests__/route.test.ts
@@ -0,0 +1,707 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ makeSelectChain,
+ makeUpdateChain,
+ makeDeleteChain,
+} from "@/__tests__/helpers/drizzle-mocks";
+import { makeRequest, makeParams } from "@/__tests__/helpers/request-helpers";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+import type { ConnectionUsage } from "@/lib/db/connection-usage";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireSession = vi.fn<
+ () => Promise<{
+ userId: string;
+ role: string;
+ canWrite: boolean;
+ tenantId: string;
+ }>
+>();
+const mockEncryptJson = vi.fn((v: unknown) => `enc:${JSON.stringify(v)}`);
+const mockDecryptJson = vi.fn(() => ({
+ uri: "bolt://localhost:7687",
+ username: "neo4j",
+ password: "secret",
+ database: "neo4j",
+ connectionTimeout: 5000,
+}));
+const mockPrefetchSchema = vi.fn();
+// Default: connection is NOT in use. Individual tests override per-scenario.
+// The explicit generic is load-bearing — without it, vi.fn's return type is
+// inferred from the default literal `dashboards: []`, pinning the array
+// element type to `never` and breaking every `.mockResolvedValue(...)` that
+// passes a real dashboard row.
+const mockGetConnectionUsage = vi.fn<() => Promise>(
+ async () => ({
+ widgetCount: 0,
+ dashboards: [],
+ }),
+);
+
+const mockDb = {
+ select: vi.fn(),
+ update: vi.fn(),
+ delete: 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/crypto/crypto", () => ({
+ encryptJson: mockEncryptJson,
+ decryptJson: mockDecryptJson,
+}));
+vi.mock("@/lib/connector/schema-prefetch", () => ({
+ prefetchSchema: mockPrefetchSchema,
+}));
+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 }));
+
+const SESSION = {
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "t1",
+};
+const ADMIN_SESSION = {
+ userId: "admin-1",
+ role: "admin",
+ canWrite: true,
+ tenantId: "t1",
+};
+
+// ---------------------------------------------------------------------------
+// GET /api/connections/[id]
+// ---------------------------------------------------------------------------
+
+describe("GET /api/connections/[id]", () => {
+ let GET: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await GET(makeRequest({}), makeParams("c1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns connection metadata in envelope (owner)", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const conn = {
+ id: "c1",
+ name: "My DB",
+ type: "postgresql",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([conn]));
+
+ const res = await GET(makeRequest({}), makeParams("c1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual(conn);
+ expect(body.error).toBeNull();
+ });
+
+ it("admin can view any connection in tenant", async () => {
+ mockRequireSession.mockResolvedValue(ADMIN_SESSION);
+ const conn = {
+ id: "c1",
+ name: "Other DB",
+ type: "neo4j",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ // First select (owner check) returns empty
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+ // Second select (admin fallback) returns the connection
+ mockDb.select.mockReturnValueOnce(makeSelectChain([conn]));
+
+ const res = await GET(makeRequest({}), makeParams("c1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.id).toBe("c1");
+ });
+
+ it("returns 404 when not found or not owned", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+
+ const res = await GET(makeRequest({}), makeParams("nonexistent"));
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.code).toBe("NOT_FOUND");
+ });
+
+ it("does not expose configEncrypted", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const conn = {
+ id: "c1",
+ name: "DB",
+ type: "neo4j",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([conn]));
+
+ const res = await GET(makeRequest({}), makeParams("c1"));
+ const body = await res.json();
+ expect(body.data.configEncrypted).toBeUndefined();
+ });
+
+ it("returns decrypted config without password", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const conn = {
+ id: "c1",
+ name: "DB",
+ type: "neo4j",
+ configEncrypted: "enc:data",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([conn]));
+
+ const res = await GET(makeRequest({}), makeParams("c1"));
+ const body = await res.json();
+ expect(body.data.config).toBeDefined();
+ expect(body.data.config.uri).toBe("bolt://localhost:7687");
+ expect(body.data.config.username).toBe("neo4j");
+ expect(body.data.config.database).toBe("neo4j");
+ expect(body.data.config.connectionTimeout).toBe(5000);
+ expect(body.data.config.password).toBeUndefined();
+ });
+
+ it("returns metadata with undefined config when configEncrypted is corrupted", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDecryptJson.mockImplementationOnce(() => {
+ throw new Error("bad cipher");
+ });
+ const conn = {
+ id: "c1",
+ name: "DB",
+ type: "neo4j",
+ configEncrypted: "enc:corrupted",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([conn]));
+
+ const res = await GET(makeRequest({}), makeParams("c1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.config).toBeUndefined();
+ expect(body.data.id).toBe("c1");
+ });
+
+ it("returns metadata with undefined config when configEncrypted is missing", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const conn = {
+ id: "c1",
+ name: "DB",
+ type: "postgresql",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ // no configEncrypted
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([conn]));
+
+ const res = await GET(makeRequest({}), makeParams("c1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.config).toBeUndefined();
+ });
+
+ it("admin fallback returns 404 when not found in tenant", async () => {
+ mockRequireSession.mockResolvedValue(ADMIN_SESSION);
+ // Both owner and admin fallback selects return empty
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+
+ const res = await GET(makeRequest({}), makeParams("nonexistent"));
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 500 on unexpected error", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockImplementation(() => {
+ throw new Error("db down");
+ });
+
+ const res = await GET(makeRequest({}), makeParams("c1"));
+ expect(res.status).toBe(500);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// PATCH /api/connections/[id]
+// ---------------------------------------------------------------------------
+
+describe("PATCH /api/connections/[id]", () => {
+ let PATCH: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ PATCH = mod.PATCH;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await PATCH(
+ makeRequest({ name: "New name" }),
+ makeParams("c1"),
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when connection not owned", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.update.mockReturnValue(makeUpdateChain([]));
+ const res = await PATCH(
+ makeRequest({ name: "New name" }),
+ makeParams("c1"),
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("updates name and returns envelope", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const updated = {
+ id: "c1",
+ name: "New name",
+ type: "neo4j",
+ updatedAt: new Date(),
+ };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ const res = await PATCH(
+ makeRequest({ name: "New name" }),
+ makeParams("c1"),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual(updated);
+ expect(body.error).toBeNull();
+ });
+
+ 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",
+ type: "neo4j",
+ updatedAt: new Date(),
+ };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ await PATCH(
+ makeRequest({
+ config: {
+ uri: "bolt://new-host",
+ username: "neo4j",
+ password: "newpass",
+ },
+ }),
+ makeParams("c1"),
+ );
+
+ expect(mockEncryptJson).toHaveBeenCalledWith({
+ uri: "bolt://new-host",
+ 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",
+ password: "newpass",
+ });
+ });
+
+ it("allows config without password (merges with existing)", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ // First select to fetch existing encrypted config
+ const existing = {
+ id: "c1",
+ configEncrypted: "enc:existing",
+ type: "neo4j",
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([existing]));
+ const updated = {
+ id: "c1",
+ name: "Neo4j",
+ type: "neo4j",
+ updatedAt: new Date(),
+ };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ const res = await PATCH(
+ makeRequest({
+ config: { uri: "bolt://new-host", username: "neo4j", database: "mydb" },
+ }),
+ makeParams("c1"),
+ );
+
+ expect(res.status).toBe(200);
+ // Should merge existing password into new config
+ expect(mockEncryptJson).toHaveBeenCalledWith(
+ expect.objectContaining({
+ uri: "bolt://new-host",
+ username: "neo4j",
+ password: "secret",
+ }),
+ );
+ });
+
+ it("does not call prefetchSchema when password is omitted", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const existing = {
+ configEncrypted: "enc:existing",
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([existing]));
+ const updated = {
+ id: "c1",
+ name: "Neo4j",
+ type: "neo4j",
+ updatedAt: new Date(),
+ };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ await PATCH(
+ makeRequest({
+ config: { uri: "bolt://new-host", username: "neo4j" },
+ }),
+ makeParams("c1"),
+ );
+
+ // prefetchSchema should still be called because the merged config has a password
+ // (merged from existing encrypted config)
+ expect(mockPrefetchSchema).toHaveBeenCalled();
+ });
+
+ it("handles config without password when no existing config exists", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ // No existing config found
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const updated = {
+ id: "c1",
+ name: "Neo4j",
+ type: "neo4j",
+ updatedAt: new Date(),
+ };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ const res = await PATCH(
+ makeRequest({
+ config: { uri: "bolt://new-host", username: "neo4j" },
+ }),
+ makeParams("c1"),
+ );
+
+ expect(res.status).toBe(200);
+ // Should encrypt the config without the password since there's no existing to merge
+ expect(mockEncryptJson).toHaveBeenCalledWith(
+ expect.objectContaining({
+ uri: "bolt://new-host",
+ username: "neo4j",
+ }),
+ );
+ // No password in final config — should not call prefetchSchema
+ expect(mockPrefetchSchema).not.toHaveBeenCalled();
+ });
+
+ it("returns 400 when body fails validation", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const res = await PATCH(
+ makeRequest({ config: { uri: "" } }), // uri must be min(1)
+ makeParams("c1"),
+ );
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toBeDefined();
+ });
+
+ it("returns 400 when stored credentials are corrupted and password is omitted", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDecryptJson.mockImplementationOnce(() => {
+ throw new Error("bad cipher");
+ });
+ const existing = { configEncrypted: "enc:corrupted" };
+ mockDb.select.mockReturnValue(makeSelectChain([existing]));
+
+ const res = await PATCH(
+ makeRequest({
+ config: { uri: "bolt://new-host", username: "neo4j" }, // no password
+ }),
+ makeParams("c1"),
+ );
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error.message).toMatch(/re-enter the password/i);
+ // Must NOT have proceeded to encrypt or update
+ expect(mockEncryptJson).not.toHaveBeenCalled();
+ });
+
+ it("updates only name without touching config when config is omitted", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const updated = {
+ id: "c1",
+ name: "Renamed",
+ type: "neo4j",
+ updatedAt: new Date(),
+ };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ const res = await PATCH(makeRequest({ name: "Renamed" }), makeParams("c1"));
+
+ expect(res.status).toBe(200);
+ expect(mockEncryptJson).not.toHaveBeenCalled();
+ expect(mockPrefetchSchema).not.toHaveBeenCalled();
+ });
+
+ it("returns 500 on unexpected error during PATCH", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.update.mockImplementation(() => {
+ throw new Error("db down");
+ });
+
+ const res = await PATCH(
+ makeRequest({ name: "New name" }),
+ makeParams("c1"),
+ );
+ expect(res.status).toBe(500);
+ });
+
+ it("calls prefetchSchema when password is explicitly provided", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const updated = {
+ id: "c1",
+ name: "PostgreSQL",
+ type: "postgresql",
+ updatedAt: new Date(),
+ };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ await PATCH(
+ makeRequest({
+ config: {
+ uri: "postgresql://localhost:5432",
+ username: "pg",
+ password: "newpass",
+ },
+ }),
+ makeParams("c1"),
+ );
+
+ expect(mockPrefetchSchema).toHaveBeenCalledWith("postgresql", {
+ uri: "postgresql://localhost:5432",
+ username: "pg",
+ password: "newpass",
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// DELETE /api/connections/[id]
+// ---------------------------------------------------------------------------
+
+describe("DELETE /api/connections/[id]", () => {
+ let DELETE: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ) => Promise;
+
+ // Helper: Request stub with a URL so `new URL(request.url)` resolves.
+ // The route parses `?force=true` from this URL.
+ const req = (url = "http://localhost/api/connections/c1") =>
+ ({ url }) as unknown as Request;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ mockGetConnectionUsage.mockResolvedValue({
+ widgetCount: 0,
+ dashboards: [],
+ });
+ const mod = await import("../route");
+ DELETE = mod.DELETE;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await DELETE(req(), makeParams("c1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when connection not found", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.delete.mockReturnValue(makeDeleteChain([]));
+ const res = await DELETE(req(), makeParams("c1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("deletes and returns envelope when no widgets reference the connection", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.delete.mockReturnValue(makeDeleteChain([{ id: "c1" }]));
+ const res = await DELETE(req(), makeParams("c1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.deleted).toBe(true);
+ expect(body.error).toBeNull();
+ });
+
+ // -------------------------------------------------------------------------
+ // #509 — in-use guard
+ // -------------------------------------------------------------------------
+
+ it("returns 409 CONFLICT when widgets reference the connection and !force", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockGetConnectionUsage.mockResolvedValue({
+ widgetCount: 3,
+ dashboards: [
+ { id: "d1", name: "Sales Overview", widgetCount: 2 },
+ { id: "d2", name: "Inventory", widgetCount: 1 },
+ ],
+ });
+
+ const res = await DELETE(req(), makeParams("c1"));
+ expect(res.status).toBe(409);
+
+ const body = await res.json();
+ expect(body.error.code).toBe("CONFLICT");
+ expect(body.error.message).toMatch(/3 widgets.*2 dashboards/);
+ expect(body.error.details.usage.widgetCount).toBe(3);
+ expect(body.error.details.usage.dashboards).toHaveLength(2);
+
+ // Critically: the delete MUST NOT have been called.
+ expect(mockDb.delete).not.toHaveBeenCalled();
+ });
+
+ it("pluralizes correctly when exactly 1 widget blocks the delete", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockGetConnectionUsage.mockResolvedValue({
+ widgetCount: 1,
+ dashboards: [{ id: "d1", name: "Dashboard A", widgetCount: 1 }],
+ });
+ const res = await DELETE(req(), makeParams("c1"));
+ expect(res.status).toBe(409);
+ const body = await res.json();
+ expect(body.error.message).toBe(
+ "Connection is in use by 1 widget across 1 dashboard",
+ );
+ });
+
+ it("?force=true bypasses the guard and deletes the in-use connection", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockGetConnectionUsage.mockResolvedValue({
+ widgetCount: 3,
+ dashboards: [{ id: "d1", name: "In-use", widgetCount: 3 }],
+ });
+ mockDb.delete.mockReturnValue(makeDeleteChain([{ id: "c1" }]));
+
+ const res = await DELETE(
+ req("http://localhost/api/connections/c1?force=true"),
+ makeParams("c1"),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.deleted).toBe(true);
+ // Usage helper is skipped entirely on the force path — no need to
+ // compute a breakdown we're about to ignore.
+ expect(mockGetConnectionUsage).not.toHaveBeenCalled();
+ expect(mockDb.delete).toHaveBeenCalled();
+ });
+
+ it("admin delete goes through the in-use guard too", async () => {
+ mockRequireSession.mockResolvedValue(ADMIN_SESSION);
+ mockGetConnectionUsage.mockResolvedValue({
+ widgetCount: 2,
+ dashboards: [{ id: "d1", name: "Tenant dash", widgetCount: 2 }],
+ });
+
+ const res = await DELETE(req(), makeParams("c1"));
+ expect(res.status).toBe(409);
+ // Admins still need to pass ?force=true to actually delete.
+ expect(mockDb.delete).not.toHaveBeenCalled();
+ });
+
+ it("admin deletes using tenant-only WHERE clause (no owner match required)", async () => {
+ mockRequireSession.mockResolvedValue(ADMIN_SESSION);
+ mockDb.delete.mockReturnValue(makeDeleteChain([{ id: "c1" }]));
+
+ const res = await DELETE(req(), makeParams("c1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.deleted).toBe(true);
+ expect(mockDb.delete).toHaveBeenCalled();
+ });
+
+ it("returns 500 on unexpected error during DELETE", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockGetConnectionUsage.mockRejectedValue(new Error("usage query failed"));
+
+ const res = await DELETE(req(), makeParams("c1"));
+ expect(res.status).toBe(500);
+ });
+
+ it("force=anything-other-than-true does NOT bypass the guard", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockGetConnectionUsage.mockResolvedValue({
+ widgetCount: 1,
+ dashboards: [{ id: "d1", name: "A", widgetCount: 1 }],
+ });
+
+ const res = await DELETE(
+ req("http://localhost/api/connections/c1?force=1"),
+ makeParams("c1"),
+ );
+ expect(res.status).toBe(409);
+ expect(mockGetConnectionUsage).toHaveBeenCalled();
+ });
+});
diff --git a/app/src/app/api/connections/[id]/databases/__tests__/route.test.ts b/app/src/app/api/connections/[id]/databases/__tests__/route.test.ts
new file mode 100644
index 000000000..b1e3c4915
--- /dev/null
+++ b/app/src/app/api/connections/[id]/databases/__tests__/route.test.ts
@@ -0,0 +1,155 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { 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 mockDb = {
+ select: vi.fn(),
+};
+const mockDecryptJson = vi.fn();
+const mockListDatabases = vi.fn();
+
+vi.mock("@/lib/auth/session", () => ({ requireSession: mockRequireSession }));
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("@/lib/crypto/crypto", () => ({
+ decryptJson: mockDecryptJson,
+ encryptJson: vi.fn(),
+}));
+vi.mock("@/lib/query/query-executor", () => ({
+ listDatabases: mockListDatabases,
+}));
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({
+ UnauthorizedError: class extends Error {
+ constructor() {
+ super("Unauthorized");
+ }
+ },
+}));
+
+const SESSION = {
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "t1",
+};
+
+function drizzleSelectChain(rows: unknown[]) {
+ const chain = {
+ from: () => chain,
+ where: () => chain,
+ limit: () => Promise.resolve(rows),
+ };
+ return chain;
+}
+
+const fakeConnection = {
+ id: "c1",
+ type: "neo4j",
+ configEncrypted: "enc",
+ userId: "user-1",
+ tenantId: "t1",
+};
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("GET /api/connections/[id]/databases", () => {
+ let GET: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new Error("Unauthorized"));
+ const res = await GET(makeRequest({}), {
+ params: Promise.resolve({ id: "c1" }),
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when connection not found", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(drizzleSelectChain([]));
+ const res = await GET(makeRequest({}), {
+ params: Promise.resolve({ id: "c1" }),
+ });
+ expect(res.status).toBe(404);
+ });
+
+ it("returns database list on success", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(drizzleSelectChain([fakeConnection]));
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ mockListDatabases.mockResolvedValue(["neo4j", "movies"]);
+
+ const res = await GET(makeRequest({}), {
+ params: Promise.resolve({ id: "c1" }),
+ });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.databases).toEqual(["neo4j", "movies"]);
+ });
+
+ it("calls listDatabases with correct type and credentials", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(drizzleSelectChain([fakeConnection]));
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ mockListDatabases.mockResolvedValue([]);
+
+ await GET(makeRequest({}), {
+ params: Promise.resolve({ id: "c1" }),
+ });
+
+ expect(mockListDatabases).toHaveBeenCalledWith("neo4j", {
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ });
+
+ it("returns empty array when listDatabases throws", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(drizzleSelectChain([fakeConnection]));
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ mockListDatabases.mockRejectedValue(new Error("Driver error"));
+
+ const res = await GET(makeRequest({}), {
+ params: Promise.resolve({ id: "c1" }),
+ });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.databases).toEqual([]);
+ });
+});
diff --git a/app/src/app/api/connections/[id]/databases/route.ts b/app/src/app/api/connections/[id]/databases/route.ts
new file mode 100644
index 000000000..f441fa621
--- /dev/null
+++ b/app/src/app/api/connections/[id]/databases/route.ts
@@ -0,0 +1,51 @@
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { connections } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { decryptJson } from "@/lib/crypto/crypto";
+import { listDatabases } from "@/lib/query/query-executor";
+import type { ConnectionCredentials, DbType } from "@/lib/query/query-executor";
+import { apiSuccess } from "@/lib/api/api-response";
+import { notFound, handleRouteError } from "@/lib/api/api-utils";
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, tenantId } = await requireSession();
+ const { id } = await params;
+
+ const [connection] = await db
+ .select()
+ .from(connections)
+ .where(
+ and(
+ eq(connections.id, id),
+ eq(connections.userId, userId),
+ eq(connections.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+
+ if (!connection) {
+ return notFound("Connection not found");
+ }
+
+ const credentials = decryptJson(
+ connection.configEncrypted,
+ );
+
+ try {
+ const databases = await listDatabases(
+ connection.type as DbType,
+ credentials,
+ );
+ return apiSuccess({ databases });
+ } catch {
+ return apiSuccess({ databases: [] });
+ }
+ } catch (error) {
+ return handleRouteError(error, "Failed to list databases");
+ }
+}
diff --git a/app/src/app/api/connections/[id]/reassign/__tests__/route.test.ts b/app/src/app/api/connections/[id]/reassign/__tests__/route.test.ts
new file mode 100644
index 000000000..96ada8757
--- /dev/null
+++ b/app/src/app/api/connections/[id]/reassign/__tests__/route.test.ts
@@ -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;
+
+ 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);
+ });
+});
diff --git a/app/src/app/api/connections/[id]/reassign/route.ts b/app/src/app/api/connections/[id]/reassign/route.ts
new file mode 100644
index 000000000..9d0d2f329
--- /dev/null
+++ b/app/src/app/api/connections/[id]/reassign/route.ts
@@ -0,0 +1,108 @@
+import { z } from "zod";
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { connections } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import {
+ validateBody,
+ notFound,
+ badRequest,
+ forbidden,
+ handleRouteError,
+} from "@/lib/api/api-utils";
+import { apiSuccess } from "@/lib/api/api-response";
+import { reassignConnectionWidgets } from "@/lib/db/connection-reassign";
+
+const reassignSchema = z.object({
+ targetConnectionId: z.string().min(1),
+});
+
+/**
+ * POST /api/connections/{id}/reassign
+ *
+ * Re-assigns every widget that references `id` (source) to
+ * `targetConnectionId` across all dashboards the caller can edit.
+ *
+ * Guards:
+ * - Source connection must exist and be owned by the caller (or the
+ * caller must be an admin in the same tenant).
+ * - Target connection must exist in the same tenant.
+ * - Target must be the same `type` as source — Cypher queries won't
+ * work on a PostgreSQL connection and vice versa.
+ *
+ * Query compatibility is NOT validated. Widgets referencing tables or
+ * nodes that don't exist on the target connection will simply fail to
+ * render at runtime — same as any other broken query.
+ *
+ * Response: `{ dashboardsUpdated, widgetsReassigned }`
+ */
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, role, canWrite, tenantId } = await requireSession();
+ if (!canWrite) return forbidden("Write permission required");
+ const { id } = await params;
+ const isAdmin = role === "admin";
+
+ const body = await request.json();
+ const validation = validateBody(reassignSchema, body);
+ if (!validation.success) return validation.response;
+ const { targetConnectionId } = validation.data;
+
+ if (targetConnectionId === id) {
+ return badRequest("Target connection must be different from source");
+ }
+
+ // Source ownership (or admin + same tenant)
+ const [source] = await db
+ .select({ id: connections.id, type: connections.type })
+ .from(connections)
+ .where(
+ isAdmin
+ ? and(eq(connections.id, id), eq(connections.tenantId, tenantId))
+ : and(
+ eq(connections.id, id),
+ eq(connections.userId, userId),
+ eq(connections.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+ if (!source) return notFound("Connection not found");
+
+ // Target must exist in the same tenant; owner doesn't have to match
+ // (admin can point at anyone's connection, and non-admins can point
+ // at a connection shared to them via a dashboard — we only enforce
+ // tenant isolation here). The type check below is the real safety.
+ const [target] = await db
+ .select({ id: connections.id, type: connections.type })
+ .from(connections)
+ .where(
+ and(
+ eq(connections.id, targetConnectionId),
+ eq(connections.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+ if (!target) return notFound("Target connection not found");
+
+ if (source.type !== target.type) {
+ return badRequest(
+ `Cannot re-assign to a ${target.type} connection — source is ${source.type}`,
+ );
+ }
+
+ const result = await reassignConnectionWidgets(
+ id,
+ targetConnectionId,
+ userId,
+ isAdmin,
+ tenantId,
+ );
+
+ return apiSuccess(result);
+ } catch (error) {
+ return handleRouteError(error, "Failed to re-assign connection widgets");
+ }
+}
diff --git a/app/src/app/api/connections/[id]/route.ts b/app/src/app/api/connections/[id]/route.ts
new file mode 100644
index 000000000..84fdea65a
--- /dev/null
+++ b/app/src/app/api/connections/[id]/route.ts
@@ -0,0 +1,266 @@
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+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 {
+ validateBody,
+ notFound,
+ handleRouteError,
+ badRequest,
+} from "@/lib/api/api-utils";
+import { apiSuccess, apiError } from "@/lib/api/api-response";
+import { getConnectionUsage } from "@/lib/db/connection-usage";
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, tenantId, role } = await requireSession();
+ const { id } = await params;
+
+ // Owner check first (tenant-scoped)
+ let [connection] = await db
+ .select({
+ id: connections.id,
+ name: connections.name,
+ type: connections.type,
+ configEncrypted: connections.configEncrypted,
+ createdAt: connections.createdAt,
+ updatedAt: connections.updatedAt,
+ })
+ .from(connections)
+ .where(
+ and(
+ eq(connections.id, id),
+ eq(connections.userId, userId),
+ eq(connections.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+
+ // Admin fallback: admin can view any connection in the same tenant.
+ if (!connection && role === "admin") {
+ [connection] = await db
+ .select({
+ id: connections.id,
+ name: connections.name,
+ type: connections.type,
+ configEncrypted: connections.configEncrypted,
+ createdAt: connections.createdAt,
+ updatedAt: connections.updatedAt,
+ })
+ .from(connections)
+ .where(and(eq(connections.id, id), eq(connections.tenantId, tenantId)))
+ .limit(1);
+ }
+
+ if (!connection) {
+ return notFound("Connection not found");
+ }
+
+ // Decrypt config and strip password before returning
+ const { configEncrypted, ...metadata } = connection;
+ let config: Record | undefined;
+ if (configEncrypted) {
+ try {
+ const decrypted = decryptJson>(configEncrypted);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- strip password from response
+ const { password, ...safeConfig } = decrypted;
+ config = safeConfig;
+ } catch {
+ // Corrupted or legacy encrypted config — return metadata without config
+ config = undefined;
+ }
+ }
+
+ return apiSuccess({ ...metadata, config });
+ } catch (error) {
+ return handleRouteError(error, "Failed to fetch connection");
+ }
+}
+
+export async function PATCH(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, tenantId } = await requireSession();
+ const { id } = await params;
+ const body = await request.json();
+ const result = validateBody(updateConnectionSchema, body);
+ if (!result.success) return result.response;
+
+ 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) {
+ const [existing] = await db
+ .select({
+ configEncrypted: connections.configEncrypted,
+ type: connections.type,
+ })
+ .from(connections)
+ .where(
+ and(
+ eq(connections.id, id),
+ eq(connections.userId, userId),
+ eq(connections.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+
+ if (existing?.configEncrypted) {
+ try {
+ const prev = decryptJson(
+ existing.configEncrypted,
+ );
+ 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.",
+ );
+ }
+ }
+
+ updates.configEncrypted = encryptJson(finalConfig);
+ }
+
+ const [connection] = await db
+ .update(connections)
+ .set(updates)
+ .where(
+ and(
+ eq(connections.id, id),
+ eq(connections.userId, userId),
+ eq(connections.tenantId, tenantId),
+ ),
+ )
+ .returning({
+ id: connections.id,
+ name: connections.name,
+ type: connections.type,
+ createdAt: connections.createdAt,
+ updatedAt: connections.updatedAt,
+ });
+
+ if (!connection) {
+ 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(
+ connection.type as ConnectorType,
+ finalConfig as { uri: string; username: string; password: string },
+ );
+ }
+
+ return apiSuccess(connection);
+ } catch (error) {
+ return handleRouteError(error, "Failed to update connection");
+ }
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, role, tenantId } = await requireSession();
+ const { id } = await params;
+ const isAdmin = role === "admin";
+
+ // `?force=true` bypasses the in-use guard. Used by the UI's
+ // "Delete anyway" button after the creator has seen the usage
+ // breakdown, and by CLI/automation that accept the data-loss tradeoff.
+ const url = new URL(request.url);
+ const force = url.searchParams.get("force") === "true";
+
+ // Before deleting, check whether any dashboard widget still
+ // references this connection. If so — and the caller hasn't
+ // acknowledged by passing `?force=true` — return 409 Conflict with
+ // the full usage breakdown so the client can render a warning.
+ //
+ // Tenant-scoped: creators see their own dashboards + shared +
+ // public; admins see every dashboard in their tenant.
+ if (!force) {
+ const usage = await getConnectionUsage(id, userId, isAdmin, tenantId);
+ if (usage.widgetCount > 0) {
+ return apiError(
+ "CONFLICT",
+ `Connection is in use by ${usage.widgetCount} widget${
+ usage.widgetCount === 1 ? "" : "s"
+ } across ${usage.dashboards.length} dashboard${
+ usage.dashboards.length === 1 ? "" : "s"
+ }`,
+ { usage },
+ );
+ }
+ }
+
+ // Ownership check is enforced by the WHERE clause below. Admins
+ // bypass the owner constraint but still require tenant match.
+ const whereClause = isAdmin
+ ? and(eq(connections.id, id), eq(connections.tenantId, tenantId))
+ : and(
+ eq(connections.id, id),
+ eq(connections.userId, userId),
+ 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)
+ .returning({ id: connections.id });
+
+ if (deleted.length === 0) {
+ 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/connections/[id]/schema/__tests__/route.test.ts b/app/src/app/api/connections/[id]/schema/__tests__/route.test.ts
new file mode 100644
index 000000000..025684b49
--- /dev/null
+++ b/app/src/app/api/connections/[id]/schema/__tests__/route.test.ts
@@ -0,0 +1,145 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeParams } 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 mockDecryptJson = vi.fn();
+const mockFetchConnectionSchema = vi.fn();
+
+function makeSelectChain(rows: unknown[]) {
+ return {
+ from: () => ({
+ where: () => ({
+ limit: () => Promise.resolve(rows),
+ }),
+ }),
+ };
+}
+
+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/crypto/crypto", () => ({ decryptJson: mockDecryptJson }));
+vi.mock("@/lib/connector/schema-prefetch", () => ({
+ fetchConnectionSchema: mockFetchConnectionSchema,
+}));
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+const SESSION = {
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "t1",
+};
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("GET /api/connections/[id]/schema", () => {
+ let GET: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await GET({} as Request, makeParams("c1"));
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.error.code).toBe("UNAUTHORIZED");
+ });
+
+ it("returns 404 when connection not found or not owned", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await GET({} as Request, makeParams("c1"));
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.code).toBe("NOT_FOUND");
+ });
+
+ it("returns schema on success", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const conn = {
+ id: "c1",
+ userId: "user-1",
+ type: "neo4j",
+ configEncrypted: "enc",
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([conn]));
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ const schema = {
+ labels: ["Person", "Movie"],
+ relationshipTypes: ["ACTED_IN"],
+ };
+ mockFetchConnectionSchema.mockResolvedValue(schema);
+
+ const res = await GET({} as Request, makeParams("c1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual(schema);
+ expect(body.error).toBeNull();
+ });
+
+ it("returns 500 when fetchConnectionSchema throws", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const conn = {
+ id: "c1",
+ userId: "user-1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([conn]));
+ mockDecryptJson.mockReturnValue({
+ uri: "pg://localhost",
+ username: "pg",
+ password: "pass",
+ });
+ mockFetchConnectionSchema.mockRejectedValue(
+ new Error("Schema fetch failed"),
+ );
+
+ const res = await GET({} as Request, makeParams("c1"));
+ expect(res.status).toBe(500);
+ const body = await res.json();
+ expect(body.error.code).toBe("INTERNAL_ERROR");
+ expect(body.error.message).toBe("Schema fetch failed");
+ });
+});
diff --git a/app/src/app/api/connections/[id]/schema/route.ts b/app/src/app/api/connections/[id]/schema/route.ts
new file mode 100644
index 000000000..9c75ff25a
--- /dev/null
+++ b/app/src/app/api/connections/[id]/schema/route.ts
@@ -0,0 +1,43 @@
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { connections } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { decryptJson } from "@/lib/crypto/crypto";
+import { fetchConnectionSchema } from "@/lib/connector/schema-prefetch";
+import type { ConnectionCredentials } from "@/lib/query/query-executor";
+import type { ConnectorType } from "@/lib/connector/connector-types";
+import { apiSuccess } from "@/lib/api/api-response";
+import { notFound, handleRouteError } from "@/lib/api/api-utils";
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId } = await requireSession();
+ const { id } = await params;
+
+ const [connection] = await db
+ .select()
+ .from(connections)
+ .where(and(eq(connections.id, id), eq(connections.userId, userId)))
+ .limit(1);
+
+ if (!connection) {
+ return notFound("Connection not found");
+ }
+
+ const credentials = decryptJson(
+ connection.configEncrypted,
+ );
+
+ const schema = await fetchConnectionSchema(
+ connection.type as ConnectorType,
+ credentials,
+ );
+
+ return apiSuccess(schema);
+ } catch (error) {
+ return handleRouteError(error, "Failed to fetch schema");
+ }
+}
diff --git a/app/src/app/api/connections/[id]/test/__tests__/route.test.ts b/app/src/app/api/connections/[id]/test/__tests__/route.test.ts
new file mode 100644
index 000000000..7d76b8100
--- /dev/null
+++ b/app/src/app/api/connections/[id]/test/__tests__/route.test.ts
@@ -0,0 +1,138 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeParams } 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 mockDecryptJson = vi.fn();
+const mockTestConnection = vi.fn();
+
+function makeSelectChain(rows: unknown[]) {
+ return {
+ from: () => ({
+ where: () => ({
+ limit: () => Promise.resolve(rows),
+ }),
+ }),
+ };
+}
+
+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/crypto/crypto", () => ({ decryptJson: mockDecryptJson }));
+vi.mock("@/lib/query/query-executor", () => ({
+ testConnection: mockTestConnection,
+}));
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+const SESSION = {
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "t1",
+};
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("POST /api/connections/[id]/test", () => {
+ let POST: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise;
+
+ 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({} as Request, makeParams("c1"));
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.error.code).toBe("UNAUTHORIZED");
+ });
+
+ it("returns 404 when connection not found or not owned", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await POST({} as Request, makeParams("c1"));
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.code).toBe("NOT_FOUND");
+ });
+
+ it("returns success:true when test passes", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const conn = {
+ id: "c1",
+ userId: "user-1",
+ type: "neo4j",
+ configEncrypted: "enc",
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([conn]));
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ mockTestConnection.mockResolvedValue(true);
+
+ const res = await POST({} as Request, makeParams("c1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.success).toBe(true);
+ expect(body.error).toBeNull();
+ });
+
+ it("returns success:false with error message when testConnection throws", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const conn = {
+ id: "c1",
+ userId: "user-1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([conn]));
+ mockDecryptJson.mockReturnValue({
+ uri: "pg://localhost",
+ username: "pg",
+ password: "pass",
+ });
+ mockTestConnection.mockRejectedValue(new Error("Connection refused"));
+
+ const res = await POST({} as Request, makeParams("c1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.success).toBe(false);
+ expect(body.data.error).toBe("Connection refused");
+ });
+});
diff --git a/app/src/app/api/connections/[id]/test/route.ts b/app/src/app/api/connections/[id]/test/route.ts
new file mode 100644
index 000000000..d5fc6fac1
--- /dev/null
+++ b/app/src/app/api/connections/[id]/test/route.ts
@@ -0,0 +1,60 @@
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { connections } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { decryptJson } from "@/lib/crypto/crypto";
+import { testConnection } from "@/lib/query/query-executor";
+import type { ConnectionCredentials, DbType } from "@/lib/query/query-executor";
+import { apiSuccess } from "@/lib/api/api-response";
+import {
+ notFound,
+ handleRouteError,
+ sanitizeErrorMessage,
+} from "@/lib/api/api-utils";
+
+export async function POST(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId } = await requireSession();
+ const { id } = await params;
+
+ const [connection] = await db
+ .select()
+ .from(connections)
+ .where(and(eq(connections.id, id), eq(connections.userId, userId)))
+ .limit(1);
+
+ if (!connection) {
+ return notFound("Connection not found");
+ }
+
+ const credentials = decryptJson(
+ connection.configEncrypted,
+ );
+
+ try {
+ const success = await testConnection(
+ connection.type as DbType,
+ credentials,
+ );
+ return apiSuccess({
+ success,
+ ...(!success ? { error: "Connection check returned false" } : {}),
+ });
+ } catch (testError) {
+ const rawMessage =
+ testError instanceof Error
+ ? testError.message
+ : "Connection test failed";
+ const message = sanitizeErrorMessage(
+ rawMessage,
+ "Connection test failed",
+ );
+ return apiSuccess({ success: false, error: message });
+ }
+ } catch (error) {
+ return handleRouteError(error, "Connection test failed");
+ }
+}
diff --git a/app/src/app/api/connections/[id]/usage/__tests__/route.test.ts b/app/src/app/api/connections/[id]/usage/__tests__/route.test.ts
new file mode 100644
index 000000000..ba725f3d2
--- /dev/null
+++ b/app/src/app/api/connections/[id]/usage/__tests__/route.test.ts
@@ -0,0 +1,148 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeSelectChain } from "@/__tests__/helpers/drizzle-mocks";
+import { makeParams } 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 mockGetConnectionUsage = 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-usage", () => ({
+ getConnectionUsage: mockGetConnectionUsage,
+}));
+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",
+};
+
+// ---------------------------------------------------------------------------
+// GET /api/connections/[id]/usage
+// ---------------------------------------------------------------------------
+
+describe("GET /api/connections/[id]/usage", () => {
+ let GET: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await GET({} as Request, makeParams("c1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when connection not found or not owned", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+
+ const res = await GET({} as Request, makeParams("c1"));
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.message).toBe("Connection not found");
+ });
+
+ it("returns usage breakdown for the owner", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([{ id: "c1" }]));
+ mockGetConnectionUsage.mockResolvedValue({
+ widgetCount: 3,
+ dashboards: [
+ { id: "d1", name: "Sales", widgetCount: 2 },
+ { id: "d2", name: "Inventory", widgetCount: 1 },
+ ],
+ });
+
+ const res = await GET({} as Request, makeParams("c1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.widgetCount).toBe(3);
+ expect(body.data.dashboards).toHaveLength(2);
+
+ // Helper was called with the creator userId + isAdmin=false
+ expect(mockGetConnectionUsage).toHaveBeenCalledWith(
+ "c1",
+ "user-1",
+ false,
+ "t1",
+ );
+ });
+
+ it("admin can query usage for any connection in the same tenant", async () => {
+ mockRequireSession.mockResolvedValue(ADMIN_SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([{ id: "c1" }]));
+ mockGetConnectionUsage.mockResolvedValue({
+ widgetCount: 5,
+ dashboards: [{ id: "d1", name: "Team dash", widgetCount: 5 }],
+ });
+
+ const res = await GET({} as Request, makeParams("c1"));
+ expect(res.status).toBe(200);
+ // Helper was called with isAdmin=true so the query returns the
+ // full tenant-wide view, not just the admin's owned dashboards.
+ expect(mockGetConnectionUsage).toHaveBeenCalledWith(
+ "c1",
+ "admin-1",
+ true,
+ "t1",
+ );
+ });
+
+ it("returns empty usage when the connection has no widgets", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([{ id: "c1" }]));
+ mockGetConnectionUsage.mockResolvedValue({
+ widgetCount: 0,
+ dashboards: [],
+ });
+
+ const res = await GET({} as Request, makeParams("c1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.widgetCount).toBe(0);
+ expect(body.data.dashboards).toEqual([]);
+ });
+});
diff --git a/app/src/app/api/connections/[id]/usage/route.ts b/app/src/app/api/connections/[id]/usage/route.ts
new file mode 100644
index 000000000..22fc18a68
--- /dev/null
+++ b/app/src/app/api/connections/[id]/usage/route.ts
@@ -0,0 +1,57 @@
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { connections } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { notFound, handleRouteError } from "@/lib/api/api-utils";
+import { apiSuccess } from "@/lib/api/api-response";
+import { getConnectionUsage } from "@/lib/db/connection-usage";
+
+/**
+ * GET /api/connections/{id}/usage
+ *
+ * Returns a breakdown of how many widgets on how many dashboards reference
+ * the given connection. Used by the UI's delete-connection confirm dialog
+ * so creators see the blast radius BEFORE they click Delete (issue #508).
+ *
+ * The same usage shape is also returned in the 409 response from
+ * DELETE /api/connections/{id} when the caller hasn't passed `?force=true`
+ * (issue #509), so the two responses are structurally identical.
+ *
+ * Permissions:
+ * - Connection must exist and belong to the caller (or the caller is admin)
+ * - Tenant-scoped both at the connection lookup AND the usage query
+ */
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, role, tenantId } = await requireSession();
+ const { id } = await params;
+
+ // Ownership check — admins bypass, creators must own the connection.
+ const isAdmin = role === "admin";
+ const [connection] = await db
+ .select({ id: connections.id })
+ .from(connections)
+ .where(
+ isAdmin
+ ? and(eq(connections.id, id), eq(connections.tenantId, tenantId))
+ : and(
+ eq(connections.id, id),
+ eq(connections.userId, userId),
+ eq(connections.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+
+ if (!connection) {
+ return notFound("Connection not found");
+ }
+
+ const usage = await getConnectionUsage(id, userId, isAdmin, tenantId);
+ return apiSuccess(usage);
+ } catch (error) {
+ return handleRouteError(error, "Failed to fetch connection usage");
+ }
+}
diff --git a/app/src/app/api/connections/__tests__/route.test.ts b/app/src/app/api/connections/__tests__/route.test.ts
new file mode 100644
index 000000000..ac27ce37d
--- /dev/null
+++ b/app/src/app/api/connections/__tests__/route.test.ts
@@ -0,0 +1,236 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ makeSelectChain,
+ makeInsertChain,
+} from "@/__tests__/helpers/drizzle-mocks";
+import { 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 mockEncryptJson = vi.fn((v: unknown) => `enc:${JSON.stringify(v)}`);
+const mockPrefetchSchema = vi.fn();
+
+const mockDb = {
+ select: vi.fn(),
+ insert: 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/crypto/crypto", () => ({
+ encryptJson: mockEncryptJson,
+ decryptJson: vi.fn(),
+}));
+vi.mock("@/lib/connector/schema-prefetch", () => ({
+ prefetchSchema: mockPrefetchSchema,
+}));
+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",
+};
+
+// ---------------------------------------------------------------------------
+// GET /api/connections
+// ---------------------------------------------------------------------------
+
+describe("GET /api/connections", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await GET(makeRequest({}, "http://localhost/api/connections"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns connections in envelope with pagination meta for non-admin", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const rows = [
+ {
+ id: "c1",
+ name: "My DB",
+ type: "postgresql",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ];
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 1 }]));
+ mockDb.select.mockReturnValueOnce(makeSelectChain(rows));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/connections"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual(rows);
+ expect(body.meta).toEqual({ total: 1, limit: 25, offset: 0 });
+ expect(body.error).toBeNull();
+ });
+
+ it("admin sees all connections in tenant", async () => {
+ mockRequireSession.mockResolvedValue(ADMIN_SESSION);
+ const rows = [
+ {
+ id: "c1",
+ name: "DB 1",
+ type: "neo4j",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ {
+ id: "c2",
+ name: "DB 2",
+ type: "postgresql",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ];
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 2 }]));
+ mockDb.select.mockReturnValueOnce(makeSelectChain(rows));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/connections"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(2);
+ });
+
+ it("respects limit and offset", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 10 }]));
+ mockDb.select.mockReturnValueOnce(
+ makeSelectChain([
+ {
+ id: "c5",
+ name: "DB 5",
+ type: "neo4j",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ]),
+ );
+
+ const res = await GET(
+ makeRequest({}, "http://localhost/api/connections?limit=1&offset=4"),
+ );
+ const body = await res.json();
+ expect(body.meta).toEqual({ total: 10, limit: 1, offset: 4 });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// POST /api/connections
+// ---------------------------------------------------------------------------
+
+describe("POST /api/connections", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request) => Promise;
+
+ 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({
+ name: "DB",
+ type: "neo4j",
+ config: {
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ },
+ }),
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 400 for invalid body (missing name)", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const res = await POST(
+ makeRequest({
+ type: "neo4j",
+ config: {
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ },
+ }),
+ );
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error.code).toBe("VALIDATION_ERROR");
+ });
+
+ it("creates connection and returns 201 envelope", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const created = {
+ id: "c1",
+ name: "Neo4j",
+ type: "neo4j",
+ createdAt: new Date(),
+ };
+ mockDb.insert.mockReturnValue(makeInsertChain([created]));
+
+ const res = await POST(
+ makeRequest({
+ name: "Neo4j",
+ type: "neo4j",
+ config: {
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ },
+ }),
+ );
+
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data).toEqual(created);
+ expect(body.error).toBeNull();
+ expect(mockEncryptJson).toHaveBeenCalled();
+ expect(mockPrefetchSchema).toHaveBeenCalled();
+ });
+});
diff --git a/app/src/app/api/connections/list-databases-inline/__tests__/route.test.ts b/app/src/app/api/connections/list-databases-inline/__tests__/route.test.ts
new file mode 100644
index 000000000..be3ce2878
--- /dev/null
+++ b/app/src/app/api/connections/list-databases-inline/__tests__/route.test.ts
@@ -0,0 +1,140 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { 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 mockListDatabases = vi.fn();
+const mockListSchemas = vi.fn();
+
+vi.mock("@/lib/auth/session", () => ({ requireSession: mockRequireSession }));
+vi.mock("@/lib/query/query-executor", () => ({
+ listDatabases: mockListDatabases,
+ listSchemas: mockListSchemas,
+}));
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({
+ UnauthorizedError: class extends Error {
+ constructor() {
+ super("Unauthorized");
+ }
+ },
+}));
+
+const SESSION = {
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "t1",
+};
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("POST /api/connections/list-databases-inline", () => {
+ let POST: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ POST = mod.POST;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new Error("Unauthorized"));
+ const res = await POST(makeRequest({}));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 400 for missing type", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const res = await POST(
+ makeRequest({ config: { uri: "x", username: "u", password: "p" } }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for invalid type", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const res = await POST(
+ makeRequest({
+ type: "mysql",
+ config: { uri: "x", username: "u", password: "p" },
+ }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns databases on success for neo4j", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockListDatabases.mockResolvedValue(["neo4j", "movies"]);
+
+ const res = await POST(
+ makeRequest({
+ type: "neo4j",
+ config: {
+ uri: "bolt://localhost:7687",
+ username: "neo4j",
+ password: "pass",
+ },
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.databases).toEqual(["neo4j", "movies"]);
+ });
+
+ it("returns databases and schemas for postgresql", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockListDatabases.mockResolvedValue(["postgres", "mydb"]);
+ mockListSchemas.mockResolvedValue(["public", "information_schema"]);
+
+ const res = await POST(
+ makeRequest({
+ type: "postgresql",
+ config: {
+ uri: "postgresql://localhost:5432/postgres",
+ username: "pg",
+ password: "pass",
+ },
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.databases).toEqual(["postgres", "mydb"]);
+ expect(body.data.schemas).toEqual(["public", "information_schema"]);
+ });
+
+ it("returns empty arrays when listing fails", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockListDatabases.mockRejectedValue(new Error("fail"));
+ mockListSchemas.mockRejectedValue(new Error("fail"));
+
+ const res = await POST(
+ makeRequest({
+ type: "postgresql",
+ config: {
+ uri: "postgresql://localhost:5432/postgres",
+ username: "pg",
+ password: "pass",
+ },
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.databases).toEqual([]);
+ expect(body.data.schemas).toEqual([]);
+ });
+});
diff --git a/app/src/app/api/connections/list-databases-inline/route.ts b/app/src/app/api/connections/list-databases-inline/route.ts
new file mode 100644
index 000000000..aa940fda9
--- /dev/null
+++ b/app/src/app/api/connections/list-databases-inline/route.ts
@@ -0,0 +1,39 @@
+import { requireSession } from "@/lib/auth/session";
+import { listDatabases, listSchemas } from "@/lib/query/query-executor";
+import type { DbType } from "@/lib/query/query-executor";
+import { testInlineSchema } from "@/lib/shared/schemas";
+import { apiSuccess } from "@/lib/api/api-response";
+import { handleRouteError, validateBody } from "@/lib/api/api-utils";
+
+export async function POST(request: Request) {
+ try {
+ await requireSession();
+ const body = await request.json();
+ const validation = validateBody(testInlineSchema, body);
+
+ if (!validation.success) {
+ return validation.response;
+ }
+
+ const { type, config } = validation.data;
+
+ const databases = await listDatabases(type as DbType, config).catch(
+ () => [] as string[],
+ );
+
+ // For PostgreSQL, also fetch schemas
+ let schemas: string[] | undefined;
+ if (type === "postgresql") {
+ schemas = await listSchemas(type as DbType, config).catch(
+ () => [] as string[],
+ );
+ }
+
+ return apiSuccess({
+ databases,
+ ...(schemas !== undefined ? { schemas } : {}),
+ });
+ } catch (error) {
+ return handleRouteError(error, "Failed to list databases");
+ }
+}
diff --git a/app/src/app/api/connections/route.ts b/app/src/app/api/connections/route.ts
new file mode 100644
index 000000000..4aba92132
--- /dev/null
+++ b/app/src/app/api/connections/route.ts
@@ -0,0 +1,83 @@
+import { and, count, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { connections } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { encryptJson } from "@/lib/crypto/crypto";
+import { prefetchSchema } from "@/lib/connector/schema-prefetch";
+import { createConnectionSchema } from "@/lib/shared/schemas";
+import { validateBody, handleRouteError } from "@/lib/api/api-utils";
+import { apiSuccess, apiList, parsePagination } from "@/lib/api/api-response";
+
+export async function GET(request: Request) {
+ try {
+ const { userId, tenantId, role } = await requireSession();
+ const { limit, offset } = parsePagination(request);
+ const isAdmin = role === "admin";
+
+ // Admin sees all connections in the tenant; non-admin sees only own.
+ const whereClause = isAdmin
+ ? eq(connections.tenantId, tenantId)
+ : and(eq(connections.userId, userId), eq(connections.tenantId, tenantId));
+
+ const [{ count: total }] = await db
+ .select({ count: count() })
+ .from(connections)
+ .where(whereClause);
+
+ const rows = await db
+ .select({
+ id: connections.id,
+ name: connections.name,
+ type: connections.type,
+ allowPerCardDb: connections.allowPerCardDb,
+ createdAt: connections.createdAt,
+ updatedAt: connections.updatedAt,
+ })
+ .from(connections)
+ .where(whereClause)
+ .limit(limit)
+ .orderBy(connections.createdAt)
+ .offset(offset);
+
+ return apiList(rows, { total: Number(total), limit, offset });
+ } catch (error) {
+ return handleRouteError(error, "Failed to fetch connections");
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const { userId, tenantId } = await requireSession();
+ const body = await request.json();
+ const result = validateBody(createConnectionSchema, body);
+ if (!result.success) return result.response;
+
+ const { name, type, config } = result.data;
+ const configEncrypted = encryptJson(config);
+
+ const [connection] = await db
+ .insert(connections)
+ .values({
+ userId,
+ tenantId,
+ name,
+ type,
+ configEncrypted,
+ })
+ .returning({
+ id: connections.id,
+ name: connections.name,
+ type: connections.type,
+ allowPerCardDb: connections.allowPerCardDb,
+ createdAt: connections.createdAt,
+ updatedAt: connections.updatedAt,
+ });
+
+ // Fire-and-forget: pre-warm the schema cache for the new connection
+ prefetchSchema(type, result.data.config);
+
+ return apiSuccess(connection, 201);
+ } catch (error) {
+ return handleRouteError(error, "Failed to create connection");
+ }
+}
diff --git a/app/src/app/api/connections/test-inline/__tests__/route.test.ts b/app/src/app/api/connections/test-inline/__tests__/route.test.ts
new file mode 100644
index 000000000..857ef1216
--- /dev/null
+++ b/app/src/app/api/connections/test-inline/__tests__/route.test.ts
@@ -0,0 +1,218 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { 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 mockTestConnection = vi.fn();
+
+vi.mock("@/lib/auth/session", () => ({ requireSession: mockRequireSession }));
+vi.mock("@/lib/query/query-executor", () => ({
+ testConnection: mockTestConnection,
+}));
+vi.mock("next/server", () => nextResponseMockFactory());
+
+const SESSION = {
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "t1",
+};
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("POST /api/connections/test-inline", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ POST = mod.POST;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new Error("Unauthorized"));
+ const res = await POST(makeRequest({}));
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.error.code).toBe("UNAUTHORIZED");
+ });
+
+ it("returns 400 for invalid body (missing type)", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const res = await POST(
+ makeRequest({ config: { uri: "x", username: "u", password: "p" } }),
+ );
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error.code).toBe("VALIDATION_ERROR");
+ });
+
+ it("returns 400 for invalid type value", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const res = await POST(
+ makeRequest({
+ type: "mysql",
+ config: { uri: "x", username: "u", password: "p" },
+ }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when config.uri is empty", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const res = await POST(
+ makeRequest({
+ type: "neo4j",
+ config: { uri: "", username: "u", password: "p" },
+ }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns success:true when test passes", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockTestConnection.mockResolvedValue(true);
+ const res = await POST(
+ makeRequest({
+ type: "neo4j",
+ config: {
+ uri: "bolt://localhost:7687",
+ username: "neo4j",
+ password: "pass",
+ },
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.success).toBe(true);
+ });
+
+ it("passes optional database to testConnection", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockTestConnection.mockResolvedValue(true);
+ await POST(
+ makeRequest({
+ type: "postgresql",
+ config: {
+ uri: "pg://localhost",
+ username: "pg",
+ password: "pass",
+ database: "mydb",
+ },
+ }),
+ );
+ expect(mockTestConnection).toHaveBeenCalledWith(
+ "postgresql",
+ expect.objectContaining({
+ uri: "pg://localhost",
+ username: "pg",
+ password: "pass",
+ database: "mydb",
+ }),
+ );
+ });
+
+ it("passes advanced pool settings to testConnection", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockTestConnection.mockResolvedValue(true);
+ await POST(
+ makeRequest({
+ type: "neo4j",
+ config: {
+ uri: "bolt://localhost:7687",
+ username: "neo4j",
+ password: "pass",
+ connectionTimeout: 5000,
+ queryTimeout: 30000,
+ maxPoolSize: 20,
+ connectionAcquisitionTimeout: 10000,
+ idleTimeout: 15000,
+ statementTimeout: 60000,
+ sslRejectUnauthorized: false,
+ },
+ }),
+ );
+ expect(mockTestConnection).toHaveBeenCalledWith(
+ "neo4j",
+ expect.objectContaining({
+ uri: "bolt://localhost:7687",
+ username: "neo4j",
+ password: "pass",
+ connectionTimeout: 5000,
+ queryTimeout: 30000,
+ maxPoolSize: 20,
+ connectionAcquisitionTimeout: 10000,
+ idleTimeout: 15000,
+ statementTimeout: 60000,
+ sslRejectUnauthorized: false,
+ }),
+ );
+ });
+
+ it("returns success:false when testConnection returns false", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockTestConnection.mockResolvedValue(false);
+ const res = await POST(
+ makeRequest({
+ type: "postgresql",
+ config: { uri: "pg://localhost", username: "pg", password: "pass" },
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.success).toBe(false);
+ });
+
+ it("returns success:false with fallback message for non-Error throws", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockTestConnection.mockRejectedValue("string error");
+ const res = await POST(
+ makeRequest({
+ type: "neo4j",
+ config: {
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ },
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.success).toBe(false);
+ expect(body.data.error).toBe("Connection test failed");
+ });
+
+ it("returns success:false with error message when testConnection throws", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockTestConnection.mockRejectedValue(new Error("Refused"));
+ const res = await POST(
+ makeRequest({
+ type: "neo4j",
+ config: {
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ },
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.success).toBe(false);
+ expect(body.data.error).toBe("Refused");
+ });
+});
diff --git a/app/src/app/api/connections/test-inline/route.ts b/app/src/app/api/connections/test-inline/route.ts
new file mode 100644
index 000000000..ffe007fbb
--- /dev/null
+++ b/app/src/app/api/connections/test-inline/route.ts
@@ -0,0 +1,56 @@
+import { requireSession } from "@/lib/auth/session";
+import { testConnection } from "@/lib/query/query-executor";
+import type { DbType } from "@/lib/query/query-executor";
+import { testInlineSchema } from "@/lib/shared/schemas";
+import { apiSuccess } from "@/lib/api/api-response";
+import {
+ handleRouteError,
+ validateBody,
+ sanitizeErrorMessage,
+} from "@/lib/api/api-utils";
+
+export async function POST(request: Request) {
+ try {
+ await requireSession();
+ const body = await request.json();
+ const validation = validateBody(testInlineSchema, body);
+
+ if (!validation.success) {
+ return validation.response;
+ }
+
+ const { type, config } = validation.data;
+
+ try {
+ const success = await testConnection(type as DbType, {
+ uri: config.uri,
+ username: config.username,
+ password: config.password,
+ database: config.database,
+ connectionTimeout: config.connectionTimeout,
+ queryTimeout: config.queryTimeout,
+ maxPoolSize: config.maxPoolSize,
+ connectionAcquisitionTimeout: config.connectionAcquisitionTimeout,
+ idleTimeout: config.idleTimeout,
+ statementTimeout: config.statementTimeout,
+ sslRejectUnauthorized: config.sslRejectUnauthorized,
+ });
+ return apiSuccess({
+ success,
+ ...(!success ? { error: "Connection check returned false" } : {}),
+ });
+ } catch (testError) {
+ const rawMessage =
+ testError instanceof Error
+ ? testError.message
+ : "Connection test failed";
+ const message = sanitizeErrorMessage(
+ rawMessage,
+ "Connection test failed",
+ );
+ return apiSuccess({ success: false, error: message });
+ }
+ } catch (error) {
+ return handleRouteError(error, "Connection test failed");
+ }
+}
diff --git a/app/src/app/api/dashboards/[id]/__tests__/route.test.ts b/app/src/app/api/dashboards/[id]/__tests__/route.test.ts
new file mode 100644
index 000000000..696a6b00a
--- /dev/null
+++ b/app/src/app/api/dashboards/[id]/__tests__/route.test.ts
@@ -0,0 +1,536 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ makeSelectChain,
+ makeUpdateChain,
+} from "@/__tests__/helpers/drizzle-mocks";
+import { makeRequest, makeParams } from "@/__tests__/helpers/request-helpers";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireSession = vi.fn<
+ () => Promise<{
+ userId: string;
+ tenantId: string;
+ canWrite: boolean;
+ role: string;
+ }>
+>();
+
+function makeDeleteChain() {
+ const c = {
+ where: () => Promise.resolve(),
+ };
+ return c;
+}
+
+const mockDb = {
+ select: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+};
+
+class UnauthorizedError extends Error {
+ constructor() {
+ super("Unauthorized");
+ }
+}
+class ForbiddenError extends Error {
+ constructor() {
+ super("Forbidden");
+ }
+}
+
+vi.mock("@/lib/auth/session", () => ({
+ requireSession: mockRequireSession,
+ requireUserId: vi.fn(),
+}));
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const SESSION = {
+ userId: "user-1",
+ tenantId: "tenant-1",
+ canWrite: true,
+ role: "creator",
+};
+
+const OWNER_DASHBOARD = {
+ id: "d1",
+ name: "Dashboard",
+ userId: "user-1",
+ tenantId: "tenant-1",
+ description: null,
+ isPublic: false,
+ layoutJson: null,
+ version: 3,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+};
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("GET /api/dashboards/[id]", () => {
+ let GET: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when dashboard not found", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("returns dashboard for owner", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.id).toBe("d1");
+ expect(body.data.role).toBe("owner");
+ });
+
+ it("returns dashboard for shared viewer", async () => {
+ mockRequireSession.mockResolvedValue({ ...SESSION, userId: "user-2" });
+ const sharedDashboard = { ...OWNER_DASHBOARD, userId: "user-1" };
+ const share = {
+ dashboardId: "d1",
+ userId: "user-2",
+ tenantId: "tenant-1",
+ role: "viewer",
+ };
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([sharedDashboard]))
+ .mockReturnValueOnce(makeSelectChain([share]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.role).toBe("viewer");
+ });
+
+ it("returns 404 when user has no access", async () => {
+ mockRequireSession.mockResolvedValue({ ...SESSION, userId: "user-2" });
+ const otherDashboard = { ...OWNER_DASHBOARD, userId: "user-1" };
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([otherDashboard]))
+ .mockReturnValueOnce(makeSelectChain([]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 404 when dashboard belongs to different tenant", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...SESSION,
+ tenantId: "tenant-other",
+ });
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("returns public dashboard as viewer for any authenticated user", async () => {
+ mockRequireSession.mockResolvedValue({ ...SESSION, userId: "user-2" });
+ const publicDashboard = { ...OWNER_DASHBOARD, isPublic: true };
+ // First select: dashboard lookup — found with isPublic=true
+ // Second select: share lookup — no share found
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([publicDashboard]))
+ .mockReturnValueOnce(makeSelectChain([]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.role).toBe("viewer");
+ });
+
+ it("returns 404 for private dashboard without share", async () => {
+ mockRequireSession.mockResolvedValue({ ...SESSION, userId: "user-2" });
+ const privateDashboard = { ...OWNER_DASHBOARD, isPublic: false };
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([privateDashboard]))
+ .mockReturnValueOnce(makeSelectChain([]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("returns dashboard for admin (bypasses per-dashboard ACL)", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...SESSION,
+ userId: "admin-1",
+ role: "admin",
+ });
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.role).toBe("admin");
+ });
+
+ it("returns updatedByName when updatedBy is set", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const dashWithUpdater = { ...OWNER_DASHBOARD, updatedBy: "user-1" };
+ // First select: canAccess finds the dashboard
+ // Second select: tenant-scoped LEFT JOIN to resolve updater name
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([dashWithUpdater]))
+ .mockReturnValueOnce(makeSelectChain([{ updatedByName: "Alice" }]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { updatedByName: string | null };
+ };
+ expect(body.data.updatedByName).toBe("Alice");
+ });
+
+ it("returns updatedByName as null when updatedBy is not set", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ // First select: canAccess. Second select: LEFT JOIN returns null updatedByName
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([OWNER_DASHBOARD]))
+ .mockReturnValueOnce(makeSelectChain([{ updatedByName: null }]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { updatedByName: string | null };
+ };
+ expect(body.data.updatedByName).toBeNull();
+ });
+});
+
+describe("PUT /api/dashboards/[id]", () => {
+ let PUT: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ PUT = mod.PUT;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await PUT(makeRequest({ name: "New name" }), makeParams("d1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 for reader role", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...SESSION,
+ canWrite: false,
+ role: "reader",
+ });
+ const res = await PUT(makeRequest({ name: "New name" }), makeParams("d1"));
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 403 when canWrite is false even for creator role", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...SESSION,
+ canWrite: false,
+ role: "creator",
+ });
+ const res = await PUT(makeRequest({ name: "New name" }), makeParams("d1"));
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 404 when not owner/editor", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await PUT(makeRequest({ name: "New name" }), makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("updates dashboard and returns 200", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ const updated = { ...OWNER_DASHBOARD, name: "New name" };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ const res = await PUT(makeRequest({ name: "New name" }), makeParams("d1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.name).toBe("New name");
+ });
+
+ it("sets updatedBy to session userId on update", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ const updated = {
+ ...OWNER_DASHBOARD,
+ name: "Updated",
+ updatedBy: "user-1",
+ };
+ const setSpy = vi.fn();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const chain: any = {
+ set: (...args: unknown[]) => {
+ setSpy(...args);
+ return chain;
+ },
+ where: () => chain,
+ returning: () => Promise.resolve([updated]),
+ };
+ mockDb.update.mockReturnValue(chain);
+
+ const res = await PUT(makeRequest({ name: "Updated" }), makeParams("d1"));
+ expect(res.status).toBe(200);
+ expect(setSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ updatedBy: "user-1" }),
+ );
+ });
+
+ it("returns 400 when request body is invalid", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ const res = await PUT(makeRequest({ name: "" }), makeParams("d1"));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when public dashboard is edited by non-owner", async () => {
+ mockRequireSession.mockResolvedValue({ ...SESSION, userId: "user-2" });
+ const publicDashboard = { ...OWNER_DASHBOARD, isPublic: true };
+ // canAccess with "editor" required: dashboard found, no share -> public only grants viewer
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([publicDashboard]))
+ .mockReturnValueOnce(makeSelectChain([]));
+ const res = await PUT(makeRequest({ name: "Hacked" }), makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 400 when refreshIntervalSeconds is below 5", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ const layout = {
+ version: 2,
+ pages: [{ id: "p1", title: "Page 1", widgets: [], gridLayout: [] }],
+ settings: { autoRefresh: true, refreshIntervalSeconds: 4 },
+ };
+ const res = await PUT(
+ makeRequest({ layoutJson: layout }),
+ makeParams("d1"),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("accepts refreshIntervalSeconds of 5 (minimum)", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ const layout = {
+ version: 2,
+ pages: [{ id: "p1", title: "Page 1", widgets: [], gridLayout: [] }],
+ settings: { autoRefresh: true, refreshIntervalSeconds: 5 },
+ };
+ const updated = { ...OWNER_DASHBOARD, layoutJson: layout };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+ const res = await PUT(
+ makeRequest({ layoutJson: layout }),
+ makeParams("d1"),
+ );
+ expect(res.status).toBe(200);
+ });
+
+ it("updates layout with v2 pages schema", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ const layout = {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "bar",
+ connectionId: "c1",
+ query: "MATCH (n) RETURN n",
+ },
+ ],
+ gridLayout: [{ i: "w1", x: 0, y: 0, w: 4, h: 3 }],
+ },
+ ],
+ };
+ const updated = { ...OWNER_DASHBOARD, layoutJson: layout };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+ const res = await PUT(
+ makeRequest({ layoutJson: layout }),
+ makeParams("d1"),
+ );
+ expect(res.status).toBe(200);
+ });
+});
+
+describe("DELETE /api/dashboards/[id]", () => {
+ let DELETE: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ DELETE = mod.DELETE;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await DELETE({} as Request, makeParams("d1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 for reader role", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...SESSION,
+ canWrite: false,
+ role: "reader",
+ });
+ const res = await DELETE({} as Request, makeParams("d1"));
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 403 when canWrite is false even for creator role", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...SESSION,
+ canWrite: false,
+ role: "creator",
+ });
+ const res = await DELETE({} as Request, makeParams("d1"));
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 404 when not owner (creator role)", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await DELETE({} as Request, makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("deletes dashboard and returns success", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ mockDb.delete.mockReturnValue(makeDeleteChain());
+ const res = await DELETE({} as Request, makeParams("d1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.deleted).toBe(true);
+ });
+
+ it("returns 404 when dashboard belongs to different tenant", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...SESSION,
+ tenantId: "tenant-other",
+ });
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await DELETE({} as Request, makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("allows admin to delete any dashboard in the tenant", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...SESSION,
+ userId: "admin-1",
+ role: "admin",
+ });
+ mockDb.select.mockReturnValue(makeSelectChain([{ id: "d1" }]));
+ mockDb.delete.mockReturnValue(makeDeleteChain());
+ const res = await DELETE({} as Request, makeParams("d1"));
+ expect(res.status).toBe(200);
+ });
+});
+
+describe("PUT /api/dashboards/[id] — optimistic locking", () => {
+ let PUT: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ PUT = mod.PUT;
+ });
+
+ // TODO: Add E2E conflict detection test (two browser contexts editing
+ // the same dashboard, second save gets 409). Deferred from this PR.
+
+ it("returns 409 when expectedVersion does not match (version conflict)", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ // canAccess check passes — OWNER_DASHBOARD has version: 3
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ // update returns empty — version mismatch (client sent 2, server has 3)
+ mockDb.update.mockReturnValue(makeUpdateChain([]));
+
+ const res = await PUT(
+ makeRequest({
+ layoutJson: {
+ version: 2,
+ pages: [{ id: "p1", title: "P", widgets: [], gridLayout: [] }],
+ },
+ expectedVersion: 2, // stale — server has version 3
+ }),
+ makeParams("d1"),
+ );
+ expect(res.status).toBe(409);
+ const body = await res.json();
+ expect(body.error.message).toMatch(/modified by someone else/i);
+ });
+
+ it("succeeds and increments version when expectedVersion matches", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ mockDb.update.mockReturnValue(
+ makeUpdateChain([{ id: "d1", version: 4, name: "Updated" }]),
+ );
+
+ const res = await PUT(
+ makeRequest({
+ name: "Updated",
+ expectedVersion: 3,
+ }),
+ makeParams("d1"),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.version).toBe(4);
+ });
+
+ it("succeeds without expectedVersion (backwards-compatible)", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([OWNER_DASHBOARD]));
+ mockDb.update.mockReturnValue(
+ makeUpdateChain([{ id: "d1", version: 2, name: "Updated" }]),
+ );
+
+ const res = await PUT(makeRequest({ name: "Updated" }), makeParams("d1"));
+ expect(res.status).toBe(200);
+ });
+});
diff --git a/app/src/app/api/dashboards/[id]/duplicate/__tests__/route.test.ts b/app/src/app/api/dashboards/[id]/duplicate/__tests__/route.test.ts
new file mode 100644
index 000000000..deb474c22
--- /dev/null
+++ b/app/src/app/api/dashboards/[id]/duplicate/__tests__/route.test.ts
@@ -0,0 +1,171 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeParams } from "@/__tests__/helpers/request-helpers";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireSession = vi.fn();
+
+function makeSelectChain(rows: unknown[]) {
+ return {
+ from: () => ({
+ where: () => ({
+ limit: () => Promise.resolve(rows),
+ }),
+ }),
+ };
+}
+
+function makeShareSelectChain(rows: unknown[]) {
+ return {
+ from: () => ({
+ where: () => ({
+ limit: () => Promise.resolve(rows),
+ }),
+ }),
+ };
+}
+
+function makeInsertChain(rows: unknown[]) {
+ return {
+ values: () => ({
+ returning: () => Promise.resolve(rows),
+ }),
+ };
+}
+
+let selectCallCount = 0;
+const mockDb = {
+ select: vi.fn(() => {
+ selectCallCount++;
+ // First select is for dashboard, second for shares
+ if (selectCallCount === 1) return makeSelectChain([]);
+ return makeShareSelectChain([]);
+ }),
+ insert: vi.fn(() => makeInsertChain([])),
+};
+
+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("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("POST /api/dashboards/[id]/duplicate", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request, ctx: { params: Promise<{ id: string }> }) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ selectCallCount = 0;
+ const mod = await import("../route");
+ POST = mod.POST;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await POST({} as Request, makeParams("d1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 when caller is reader", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "u1", role: "reader", canWrite: false, tenantId: "default" });
+ const res = await POST({} as Request, makeParams("d1"));
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.message).toBe("Forbidden");
+ });
+
+ it("returns 404 when dashboard not found", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "u1", role: "creator", canWrite: true, tenantId: "default" });
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+ const res = await POST({} as Request, makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 201 and copies dashboard for owner", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "u1", role: "creator", canWrite: true, tenantId: "default" });
+ const source = {
+ id: "d1",
+ userId: "u1",
+ name: "My Dashboard",
+ description: "desc",
+ layoutJson: { version: 2, pages: [] },
+ isPublic: true,
+ };
+ const copy = { ...source, id: "d2", name: "My Dashboard (copy)", isPublic: false };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([source]));
+ mockDb.insert.mockReturnValueOnce(makeInsertChain([copy]));
+
+ const res = await POST({} as Request, makeParams("d1"));
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data.name).toBe("My Dashboard (copy)");
+ });
+
+ it("admin can duplicate any dashboard (bypasses ownership)", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "admin-1", role: "admin", canWrite: true, tenantId: "default" });
+ const source = { id: "d1", userId: "other-user", name: "Other Dashboard" };
+ const copy = { id: "d2", name: "Other Dashboard (copy)" };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([source]));
+ mockDb.insert.mockReturnValueOnce(makeInsertChain([copy]));
+
+ const res = await POST({} as Request, makeParams("d1"));
+ expect(res.status).toBe(201);
+ });
+
+ it("returns 404 when creator is not owner and has no share", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "u2", role: "creator", canWrite: true, tenantId: "default" });
+ const source = { id: "d1", userId: "u1", name: "Other Dashboard" };
+ // First select: dashboard found (owned by u1)
+ mockDb.select.mockReturnValueOnce(makeSelectChain([source]));
+ // Second select: no share entry for u2
+ mockDb.select.mockReturnValueOnce(makeShareSelectChain([]));
+
+ const res = await POST({} as Request, makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("allows duplication when creator has share entry", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "u2", role: "creator", canWrite: true, tenantId: "default" });
+ const source = {
+ id: "d1",
+ userId: "u1",
+ name: "Shared Dashboard",
+ description: null,
+ layoutJson: { version: 2, pages: [] },
+ };
+ const copy = { id: "d2", name: "Shared Dashboard (copy)" };
+ // First select: dashboard found
+ mockDb.select.mockReturnValueOnce(makeSelectChain([source]));
+ // Second select: share entry exists
+ mockDb.select.mockReturnValueOnce(makeShareSelectChain([{ id: "share-1" }]));
+ mockDb.insert.mockReturnValueOnce(makeInsertChain([copy]));
+
+ const res = await POST({} as Request, makeParams("d1"));
+ expect(res.status).toBe(201);
+ });
+
+ it("returns 403 when canWrite is false", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "u1", role: "creator", canWrite: false, tenantId: "default" });
+ const res = await POST({} as Request, makeParams("d1"));
+ expect(res.status).toBe(403);
+ });
+});
diff --git a/app/src/app/api/dashboards/[id]/duplicate/route.ts b/app/src/app/api/dashboards/[id]/duplicate/route.ts
new file mode 100644
index 000000000..f9b45de7c
--- /dev/null
+++ b/app/src/app/api/dashboards/[id]/duplicate/route.ts
@@ -0,0 +1,75 @@
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { dashboards, dashboardShares } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { forbidden, notFound, handleRouteError } from "@/lib/api/api-utils";
+import { apiSuccess } from "@/lib/api/api-response";
+
+export async function POST(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const {
+ userId,
+ role: userRole,
+ canWrite,
+ tenantId,
+ } = await requireSession();
+ const { id } = await params;
+
+ if (!canWrite || userRole === "reader") {
+ return forbidden();
+ }
+
+ // Verify the caller can view the source dashboard (scoped to tenant)
+ const [source] = await db
+ .select()
+ .from(dashboards)
+ .where(and(eq(dashboards.id, id), eq(dashboards.tenantId, tenantId)))
+ .limit(1);
+
+ if (!source) {
+ return notFound();
+ }
+
+ // Non-admin Creators can only duplicate dashboards they own or are assigned to
+ if (userRole !== "admin") {
+ const isOwner = source.userId === userId;
+ if (!isOwner) {
+ const [share] = await db
+ .select({ id: dashboardShares.id })
+ .from(dashboardShares)
+ .where(
+ and(
+ eq(dashboardShares.dashboardId, id),
+ eq(dashboardShares.userId, userId),
+ eq(dashboardShares.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+
+ if (!share) {
+ return notFound();
+ }
+ }
+ }
+
+ const [copy] = await db
+ .insert(dashboards)
+ .values({
+ userId,
+ tenantId,
+ name: `${source.name} (copy)`,
+ description: source.description,
+ layoutJson: source.layoutJson,
+ isPublic: false,
+ updatedBy: userId,
+ })
+ .returning();
+
+ return apiSuccess(copy, 201);
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
diff --git a/app/src/app/api/dashboards/[id]/export/__tests__/route.test.ts b/app/src/app/api/dashboards/[id]/export/__tests__/route.test.ts
new file mode 100644
index 000000000..d4103e222
--- /dev/null
+++ b/app/src/app/api/dashboards/[id]/export/__tests__/route.test.ts
@@ -0,0 +1,259 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeSelectChain } from "@/__tests__/helpers/drizzle-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireSession = vi.fn<
+ () => Promise<{ userId: string; role: string; canWrite: boolean; tenantId: string }>
+>();
+
+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/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const SESSION = { userId: "user-1", role: "creator", canWrite: true, tenantId: "tenant-1" };
+
+const DASHBOARD_ROW = {
+ id: "dash-1",
+ name: "My Dashboard",
+ description: "A test dashboard",
+ tenantId: "tenant-1",
+ userId: "user-1",
+ isPublic: false,
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ { id: "w1", chartType: "bar", connectionId: "conn-abc", query: "MATCH (n) RETURN n", settings: {} },
+ { id: "w2", chartType: "table", connectionId: "conn-abc", query: "MATCH (m) RETURN m", settings: {} },
+ ],
+ gridLayout: [
+ { i: "w1", x: 0, y: 0, w: 6, h: 4 },
+ { i: "w2", x: 6, y: 0, w: 6, h: 4 },
+ ],
+ },
+ ],
+ },
+ createdAt: new Date(),
+ updatedAt: new Date(),
+};
+
+const CONNECTION_ROW = { id: "conn-abc", name: "Neo4j Prod", type: "neo4j" };
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("GET /api/dashboards/[id]/export", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: (req: Request, ctx: { params: Promise<{ id: string }> }) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+
+ const res = await GET(new Request("http://localhost"), {
+ params: Promise.resolve({ id: "dash-1" }),
+ });
+
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.error.code).toBe("UNAUTHORIZED");
+ });
+
+ it("returns 404 when dashboard is not found", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+
+ const res = await GET(new Request("http://localhost"), {
+ params: Promise.resolve({ id: "nonexistent" }),
+ });
+
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.code).toBe("NOT_FOUND");
+ });
+
+ it("returns 200 with export payload and download headers", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ // Dashboard query
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD_ROW]));
+ // Connections query
+ mockDb.select.mockReturnValueOnce(makeSelectChain([CONNECTION_ROW]));
+
+ const res = await GET(new Request("http://localhost"), {
+ params: Promise.resolve({ id: "dash-1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("application/json");
+ expect(res.headers.get("Content-Disposition")).toContain("dashboard-my-dashboard.json");
+
+ const body = await res.json();
+ expect(body.formatVersion).toBe(1);
+ expect(body.dashboard.name).toBe("My Dashboard");
+ expect(body.dashboard.description).toBe("A test dashboard");
+ expect(body.connections).toHaveProperty("conn_0");
+ expect(body.connections.conn_0).toEqual({ name: "Neo4j Prod", type: "neo4j" });
+ });
+
+ it("exports dashboard with no connections (empty layout)", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const emptyDash = {
+ ...DASHBOARD_ROW,
+ layoutJson: {
+ version: 2,
+ pages: [{ id: "p1", title: "Page 1", widgets: [], gridLayout: [] }],
+ },
+ };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([emptyDash]));
+
+ const res = await GET(new Request("http://localhost"), {
+ params: Promise.resolve({ id: "dash-1" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.connections).toEqual({});
+ expect(body.layout.pages[0].widgets).toEqual([]);
+ });
+
+ it("exports dashboard with null layout", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const nullLayoutDash = {
+ ...DASHBOARD_ROW,
+ layoutJson: null,
+ };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([nullLayoutDash]));
+
+ const res = await GET(new Request("http://localhost"), {
+ params: Promise.resolve({ id: "dash-1" }),
+ });
+
+ // buildExportPayload will throw when layout is null → 500
+ expect(res.status).toBe(500);
+ });
+
+ it("slugifies dashboard name for filename", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const specialNameDash = {
+ ...DASHBOARD_ROW,
+ name: "My Amazing!! Dashboard #1",
+ };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([specialNameDash]));
+ mockDb.select.mockReturnValueOnce(makeSelectChain([CONNECTION_ROW]));
+
+ const res = await GET(new Request("http://localhost"), {
+ params: Promise.resolve({ id: "dash-1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Disposition")).toContain("dashboard-my-amazing-dashboard-1.json");
+ });
+
+ it("returns 500 for unexpected errors", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockImplementationOnce(() => {
+ throw new Error("DB connection failed");
+ });
+
+ const res = await GET(new Request("http://localhost"), {
+ params: Promise.resolve({ id: "dash-1" }),
+ });
+
+ expect(res.status).toBe(500);
+ const body = await res.json();
+ expect(body.error.code).toBe("INTERNAL_ERROR");
+ });
+
+ it("scopes connection query to userId", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD_ROW]));
+ // Return empty connections — simulates user not owning the connection
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+
+ const res = await GET(new Request("http://localhost"), {
+ params: Promise.resolve({ id: "dash-1" }),
+ });
+
+ // buildExportPayload throws when connection row is missing
+ expect(res.status).toBe(500);
+ // Verify select was called twice (dashboard + connections)
+ expect(mockDb.select).toHaveBeenCalledTimes(2);
+ });
+
+ it("handles widgets with empty connectionId", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ const noConnDash = {
+ ...DASHBOARD_ROW,
+ layoutJson: {
+ version: 2,
+ pages: [{
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ { id: "w1", chartType: "bar", connectionId: "", query: "RETURN 1", settings: {} },
+ ],
+ gridLayout: [{ i: "w1", x: 0, y: 0, w: 6, h: 4 }],
+ }],
+ },
+ };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([noConnDash]));
+
+ const res = await GET(new Request("http://localhost"), {
+ params: Promise.resolve({ id: "dash-1" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.connections).toEqual({});
+ });
+
+ it("deduplicates connectionIds across multiple widgets", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ // Dashboard has 2 widgets sharing the same connection
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD_ROW]));
+ mockDb.select.mockReturnValueOnce(makeSelectChain([CONNECTION_ROW]));
+
+ const res = await GET(new Request("http://localhost"), {
+ params: Promise.resolve({ id: "dash-1" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ // Only one connection key despite two widgets using the same connectionId
+ expect(Object.keys(body.connections)).toHaveLength(1);
+ });
+});
diff --git a/app/src/app/api/dashboards/[id]/export/route.ts b/app/src/app/api/dashboards/[id]/export/route.ts
new file mode 100644
index 000000000..26d90575c
--- /dev/null
+++ b/app/src/app/api/dashboards/[id]/export/route.ts
@@ -0,0 +1,102 @@
+import { and, eq, inArray } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { connections, dashboards, dashboardShares } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { buildExportPayload } from "@/lib/dashboard/dashboard-export";
+import { notFound, handleRouteError } from "@/lib/api/api-utils";
+import type { DashboardLayoutV2 } from "@/lib/db/schema";
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, tenantId, role } = await requireSession();
+ const { id } = await params;
+
+ // Fetch the dashboard with tenant scoping
+ const [dashboard] = await db
+ .select()
+ .from(dashboards)
+ .where(and(eq(dashboards.id, id), eq(dashboards.tenantId, tenantId)))
+ .limit(1);
+
+ if (!dashboard) {
+ return notFound("Dashboard not found");
+ }
+
+ // Access control: admin can export any dashboard in the tenant;
+ // owners and shared users (viewer or editor) can export;
+ // public dashboards are exportable by any tenant user.
+ if (role !== "admin" && dashboard.userId !== userId) {
+ const [share] = await db
+ .select({ id: dashboardShares.id })
+ .from(dashboardShares)
+ .where(
+ and(
+ eq(dashboardShares.dashboardId, id),
+ eq(dashboardShares.userId, userId),
+ eq(dashboardShares.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+
+ if (!share && !dashboard.isPublic) {
+ return notFound("Dashboard not found");
+ }
+ }
+
+ const layout = dashboard.layoutJson as DashboardLayoutV2 | null;
+
+ // Gather unique non-empty connectionIds from all pages
+ const connectionIds = new Set();
+ if (layout?.pages) {
+ for (const page of layout.pages) {
+ for (const widget of page.widgets) {
+ if (widget.connectionId) {
+ connectionIds.add(widget.connectionId);
+ }
+ }
+ }
+ }
+
+ // Load connection name + type (no credentials).
+ // Include connections owned by the user OR referenced by dashboards
+ // they have access to (admin sees all in tenant context).
+ let connectionRows: { id: string; name: string; type: string }[] = [];
+ if (connectionIds.size > 0) {
+ connectionRows = await db
+ .select({
+ id: connections.id,
+ name: connections.name,
+ type: connections.type,
+ })
+ .from(connections)
+ .where(
+ and(
+ inArray(connections.id, [...connectionIds]),
+ eq(connections.tenantId, tenantId),
+ role === "admin" ? undefined : eq(connections.userId, userId),
+ ),
+ );
+ }
+
+ const payload = buildExportPayload(dashboard, connectionRows);
+
+ // Slugify name for filename
+ const slug = dashboard.name
+ .toLowerCase()
+ .replaceAll(/[^a-z0-9]+/g, "-")
+ .replaceAll(/^-|-$/g, "");
+
+ return new Response(JSON.stringify(payload, null, 2), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Disposition": `attachment; filename="dashboard-${slug}.json"`,
+ },
+ });
+ } catch (error) {
+ return handleRouteError(error, "Failed to export dashboard");
+ }
+}
diff --git a/app/src/app/api/dashboards/[id]/route.ts b/app/src/app/api/dashboards/[id]/route.ts
new file mode 100644
index 000000000..6e38a7dde
--- /dev/null
+++ b/app/src/app/api/dashboards/[id]/route.ts
@@ -0,0 +1,269 @@
+import { z } from "zod";
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { dashboards, dashboardShares, users } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import type { UserRole } from "@/lib/db/schema";
+import {
+ validateBody,
+ forbidden,
+ notFound,
+ handleRouteError,
+} from "@/lib/api/api-utils";
+import { apiSuccess, apiError } from "@/lib/api/api-response";
+import { sql } from "drizzle-orm";
+
+const gridLayoutItemSchema = z.object({
+ i: z.string(),
+ x: z.number(),
+ y: z.number(),
+ w: z.number(),
+ h: z.number(),
+});
+
+const widgetSchema = z
+ .object({
+ id: z.string(),
+ chartType: z.string(),
+ connectionId: z.string(),
+ query: z.string(),
+ params: z.record(z.unknown()).optional(),
+ settings: z.record(z.unknown()).optional(),
+ })
+ .passthrough(); // preserves templateId, templateSyncedAt and any future fields
+
+const pageSchema = z.object({
+ id: z.string(),
+ title: z.string().min(1),
+ widgets: z.array(widgetSchema),
+ gridLayout: z.array(gridLayoutItemSchema),
+});
+
+const dashboardSettingsSchema = z.object({
+ autoRefresh: z.boolean().optional(),
+ refreshIntervalSeconds: z.number().min(5).optional(),
+});
+
+/** Each thumbnail must be a data-URI under 50 KB. */
+const thumbnailValueSchema = z.string().startsWith("data:image/").max(50_000);
+
+const updateDashboardSchema = z.object({
+ name: z.string().min(1).optional(),
+ description: z.string().optional(),
+ layoutJson: z
+ .object({
+ version: z.literal(2),
+ pages: z.array(pageSchema).min(1),
+ settings: dashboardSettingsSchema.optional(),
+ })
+ .optional(),
+ isPublic: z.boolean().optional(),
+ thumbnailJson: z.record(thumbnailValueSchema).optional(),
+ /** Optimistic lock — must match the server's current version. */
+ expectedVersion: z.number().int().positive().optional(),
+});
+
+type DashboardAccessRole = "owner" | "editor" | "viewer" | "admin";
+
+async function canAccess(
+ dashboardId: string,
+ userId: string,
+ tenantId: string,
+ userRole: UserRole,
+ requiredRole: "viewer" | "editor" | "owner",
+): Promise<{
+ dashboard: typeof dashboards.$inferSelect;
+ role: DashboardAccessRole;
+} | null> {
+ const [dashboard] = await db
+ .select()
+ .from(dashboards)
+ .where(
+ and(eq(dashboards.id, dashboardId), eq(dashboards.tenantId, tenantId)),
+ )
+ .limit(1);
+
+ if (!dashboard) return null;
+
+ // Admins bypass per-dashboard ACL
+ if (userRole === "admin") return { dashboard, role: "admin" };
+
+ if (dashboard.userId === userId) return { dashboard, role: "owner" };
+
+ const [share] = await db
+ .select()
+ .from(dashboardShares)
+ .where(
+ and(
+ eq(dashboardShares.dashboardId, dashboardId),
+ eq(dashboardShares.userId, userId),
+ eq(dashboardShares.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+
+ if (!share) {
+ // Public dashboards grant read-only access to any authenticated tenant user
+ if (dashboard.isPublic && requiredRole === "viewer") {
+ return { dashboard, role: "viewer" as const };
+ }
+ return null;
+ }
+
+ if (requiredRole === "owner") return null;
+ if (requiredRole === "editor" && share.role === "viewer") return null;
+
+ return { dashboard, role: share.role };
+}
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, tenantId, role: userRole } = await requireSession();
+ const { id } = await params;
+
+ const access = await canAccess(id, userId, tenantId, userRole, "viewer");
+ if (!access) {
+ return notFound();
+ }
+
+ // Look up the name of the user who last updated this dashboard (tenant-scoped)
+ const [metadata] = await db
+ .select({ updatedByName: users.name })
+ .from(dashboards)
+ .leftJoin(users, eq(dashboards.updatedBy, users.id))
+ .where(and(eq(dashboards.id, id), eq(dashboards.tenantId, tenantId)))
+ .limit(1);
+
+ return apiSuccess({
+ ...access.dashboard,
+ role: access.role,
+ updatedByName: metadata?.updatedByName ?? null,
+ });
+ } catch (error) {
+ return handleRouteError(error, "Failed to fetch dashboard");
+ }
+}
+
+export async function PUT(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const {
+ userId,
+ tenantId,
+ role: userRole,
+ canWrite,
+ } = await requireSession();
+ const { id } = await params;
+
+ if (!canWrite) {
+ return forbidden();
+ }
+
+ const access = await canAccess(id, userId, tenantId, userRole, "editor");
+ if (!access) {
+ return notFound();
+ }
+
+ const body = await request.json();
+ const result = validateBody(updateDashboardSchema, body);
+ if (!result.success) return result.response;
+
+ const { expectedVersion, ...updateData } = result.data;
+
+ // Build WHERE clause — always scope by id + tenant; add version
+ // check when the client sends expectedVersion (optimistic lock).
+ const conditions = [
+ eq(dashboards.id, id),
+ eq(dashboards.tenantId, tenantId),
+ ];
+ if (expectedVersion !== undefined) {
+ conditions.push(eq(dashboards.version, expectedVersion));
+ }
+
+ // Only increment version on meaningful edits — thumbnails-only or
+ // settings-only saves should not bump version and trigger the
+ // "updated by X" banner in other viewers' browsers.
+ const isMeaningfulEdit =
+ expectedVersion !== undefined ||
+ updateData.layoutJson !== undefined ||
+ updateData.name !== undefined ||
+ updateData.description !== undefined ||
+ updateData.isPublic !== undefined;
+
+ const [updated] = await db
+ .update(dashboards)
+ .set({
+ ...updateData,
+ updatedAt: new Date(),
+ updatedBy: userId,
+ ...(isMeaningfulEdit
+ ? { version: sql`${dashboards.version} + 1` }
+ : {}),
+ })
+ .where(and(...conditions))
+ .returning();
+
+ if (!updated) {
+ // Row exists (canAccess passed) but version didn't match →
+ // another user saved since the client last fetched.
+ return apiError(
+ "CONFLICT",
+ "This dashboard was modified by someone else. Reload to see their changes.",
+ );
+ }
+
+ return apiSuccess(updated);
+ } catch (error) {
+ return handleRouteError(error, "Failed to update dashboard");
+ }
+}
+
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const {
+ userId,
+ tenantId,
+ role: userRole,
+ canWrite,
+ } = await requireSession();
+ const { id } = await params;
+
+ if (!canWrite) {
+ return forbidden();
+ }
+
+ // Admin can delete any dashboard in the tenant; Creator only their own
+ if (userRole === "admin") {
+ const [dashboard] = await db
+ .select({ id: dashboards.id })
+ .from(dashboards)
+ .where(and(eq(dashboards.id, id), eq(dashboards.tenantId, tenantId)))
+ .limit(1);
+
+ if (!dashboard) {
+ return notFound();
+ }
+ } else {
+ const access = await canAccess(id, userId, tenantId, userRole, "owner");
+ if (!access) {
+ return notFound();
+ }
+ }
+
+ await db
+ .delete(dashboards)
+ .where(and(eq(dashboards.id, id), eq(dashboards.tenantId, tenantId)));
+
+ return apiSuccess({ deleted: true });
+ } catch (error) {
+ return handleRouteError(error, "Failed to delete dashboard");
+ }
+}
diff --git a/app/src/app/api/dashboards/[id]/share/__tests__/route.test.ts b/app/src/app/api/dashboards/[id]/share/__tests__/route.test.ts
new file mode 100644
index 000000000..baa2570bb
--- /dev/null
+++ b/app/src/app/api/dashboards/[id]/share/__tests__/route.test.ts
@@ -0,0 +1,263 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeSelectChain } from "@/__tests__/helpers/drizzle-mocks";
+import { makeRequest, makeParams } 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 }>
+>();
+
+function makeInsertChain() {
+ return { values: () => Promise.resolve() };
+}
+
+function makeUpdateChain() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const c: any = { set: () => c, where: () => Promise.resolve() };
+ return c;
+}
+
+function makeDeleteChain() {
+ return { where: () => Promise.resolve() };
+}
+
+const mockDb = {
+ select: vi.fn(),
+ insert: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+};
+
+class UnauthorizedError extends Error {
+ constructor() {
+ super("Unauthorized");
+ }
+}
+class ForbiddenError extends Error {
+ constructor() {
+ super("Forbidden");
+ }
+}
+
+vi.mock("@/lib/auth/session", () => ({
+ requireSession: mockRequireSession,
+ requireUserId: vi.fn(),
+}));
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeDeleteRequest(url: string) {
+ return { url } as Request;
+}
+
+const SESSION = { userId: "user-1", role: "creator", canWrite: true, tenantId: "default" };
+const ADMIN_SESSION = { userId: "admin-1", role: "admin", canWrite: true, tenantId: "default" };
+const DASHBOARD = { id: "d1", userId: "user-1", tenantId: "default", name: "Dash" };
+
+// ---------------------------------------------------------------------------
+// GET
+// ---------------------------------------------------------------------------
+
+describe("GET /api/dashboards/[id]/share", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: (req: Request, ctx: { params: Promise<{ id: string }> }) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when dashboard not found", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("returns shares for dashboard owner", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD]));
+ const shares = [
+ { id: "s1", role: "viewer", createdAt: new Date(), userName: "Alice", userEmail: "alice@example.com" },
+ ];
+ mockDb.select.mockReturnValueOnce(makeSelectChain(shares));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(1);
+ });
+
+ it("returns shares for admin accessing any dashboard", async () => {
+ mockRequireSession.mockResolvedValue(ADMIN_SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ ...DASHBOARD, userId: "someone-else" }]));
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+ const res = await GET({} as Request, makeParams("d1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// POST
+// ---------------------------------------------------------------------------
+
+describe("POST /api/dashboards/[id]/share", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request, ctx: { params: Promise<{ id: string }> }) => Promise;
+
+ 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({ email: "a@b.com", role: "viewer" }), makeParams("d1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when dashboard not found", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+ const res = await POST(makeRequest({ email: "a@b.com", role: "viewer" }), makeParams("d1"));
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 400 when email is invalid", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD]));
+ const res = await POST(makeRequest({ email: "not-an-email", role: "viewer" }), makeParams("d1"));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when role is invalid", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD]));
+ const res = await POST(makeRequest({ email: "a@b.com", role: "owner" }), makeParams("d1"));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when target user not found", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD]));
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+ const res = await POST(makeRequest({ email: "unknown@example.com", role: "viewer" }), makeParams("d1"));
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.message).toBe("User not found");
+ });
+
+ it("returns 400 when sharing with yourself", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD]));
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ id: "user-1" }]));
+ const res = await POST(makeRequest({ email: "self@example.com", role: "viewer" }), makeParams("d1"));
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error.message).toBe("Cannot share with yourself");
+ });
+
+ it("creates new share when none exists", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD]));
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ id: "user-2" }]));
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+ mockDb.insert.mockReturnValue(makeInsertChain());
+ const res = await POST(makeRequest({ email: "other@example.com", role: "viewer" }), makeParams("d1"));
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data.success).toBe(true);
+ });
+
+ it("updates existing share role (upsert)", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD]));
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ id: "user-2" }]));
+ mockDb.select.mockReturnValueOnce(
+ makeSelectChain([{ id: "s1", dashboardId: "d1", userId: "user-2", role: "viewer" }])
+ );
+ mockDb.update.mockReturnValue(makeUpdateChain());
+ const res = await POST(makeRequest({ email: "other@example.com", role: "editor" }), makeParams("d1"));
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data.success).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// DELETE
+// ---------------------------------------------------------------------------
+
+describe("DELETE /api/dashboards/[id]/share", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let DELETE: (req: Request, ctx: { params: Promise<{ id: string }> }) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ DELETE = mod.DELETE;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await DELETE(
+ makeDeleteRequest("http://localhost/api/dashboards/d1/share"),
+ makeParams("d1")
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when dashboard not found", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+ const res = await DELETE(
+ makeDeleteRequest("http://localhost/api/dashboards/d1/share"),
+ makeParams("d1")
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 400 when shareId is missing", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD]));
+ const res = await DELETE(
+ makeDeleteRequest("http://localhost/api/dashboards/d1/share"),
+ makeParams("d1")
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("deletes share and returns success", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([DASHBOARD]));
+ mockDb.delete.mockReturnValue(makeDeleteChain());
+ const res = await DELETE(
+ makeDeleteRequest("http://localhost/api/dashboards/d1/share?shareId=s1"),
+ makeParams("d1")
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.success).toBe(true);
+ });
+});
diff --git a/app/src/app/api/dashboards/[id]/share/route.ts b/app/src/app/api/dashboards/[id]/share/route.ts
new file mode 100644
index 000000000..899910c74
--- /dev/null
+++ b/app/src/app/api/dashboards/[id]/share/route.ts
@@ -0,0 +1,206 @@
+import { z } from "zod";
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { dashboards, dashboardShares, users } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import {
+ validateBody,
+ notFound,
+ badRequest,
+ handleRouteError,
+} from "@/lib/api/api-utils";
+import { apiSuccess } from "@/lib/api/api-response";
+
+const shareSchema = z.object({
+ email: z.string().email(),
+ role: z.enum(["viewer", "editor"]),
+});
+
+/**
+ * Verify the caller has permission to manage shares for this dashboard.
+ * Admin can manage any dashboard in the tenant; others must own it.
+ */
+async function requireShareAccess(
+ dashboardId: string,
+ userId: string,
+ isAdmin: boolean,
+ tenantId: string,
+) {
+ if (isAdmin) {
+ const [dashboard] = await db
+ .select()
+ .from(dashboards)
+ .where(
+ and(eq(dashboards.id, dashboardId), eq(dashboards.tenantId, tenantId)),
+ )
+ .limit(1);
+ return dashboard ?? null;
+ }
+
+ const [dashboard] = await db
+ .select()
+ .from(dashboards)
+ .where(
+ and(
+ eq(dashboards.id, dashboardId),
+ eq(dashboards.userId, userId),
+ eq(dashboards.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+
+ return dashboard ?? null;
+}
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, role, tenantId } = await requireSession();
+ const { id } = await params;
+
+ const dashboard = await requireShareAccess(
+ id,
+ userId,
+ role === "admin",
+ tenantId,
+ );
+ if (!dashboard) {
+ return notFound();
+ }
+
+ const shares = await db
+ .select({
+ id: dashboardShares.id,
+ role: dashboardShares.role,
+ createdAt: dashboardShares.createdAt,
+ userName: users.name,
+ userEmail: users.email,
+ })
+ .from(dashboardShares)
+ .innerJoin(users, eq(dashboardShares.userId, users.id))
+ .where(
+ and(
+ eq(dashboardShares.dashboardId, id),
+ eq(dashboardShares.tenantId, tenantId),
+ ),
+ );
+
+ return apiSuccess(shares);
+ } catch (error) {
+ return handleRouteError(error, "Failed to fetch shares");
+ }
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, role, tenantId } = await requireSession();
+ const { id } = await params;
+
+ const dashboard = await requireShareAccess(
+ id,
+ userId,
+ role === "admin",
+ tenantId,
+ );
+ if (!dashboard) {
+ return notFound();
+ }
+
+ const body = await request.json();
+ const result = validateBody(shareSchema, body);
+ if (!result.success) return result.response;
+
+ // Find user by email within same tenant
+ const [targetUser] = await db
+ .select({ id: users.id })
+ .from(users)
+ .where(
+ and(eq(users.email, result.data.email), eq(users.tenantId, tenantId)),
+ )
+ .limit(1);
+
+ if (!targetUser) {
+ return notFound("User not found");
+ }
+
+ if (targetUser.id === userId) {
+ return badRequest("Cannot share with yourself");
+ }
+
+ // Upsert share
+ const existing = await db
+ .select()
+ .from(dashboardShares)
+ .where(
+ and(
+ eq(dashboardShares.dashboardId, id),
+ eq(dashboardShares.userId, targetUser.id),
+ ),
+ )
+ .limit(1);
+
+ if (existing.length > 0) {
+ await db
+ .update(dashboardShares)
+ .set({ role: result.data.role })
+ .where(eq(dashboardShares.id, existing[0].id));
+ } else {
+ await db.insert(dashboardShares).values({
+ dashboardId: id,
+ userId: targetUser.id,
+ tenantId,
+ role: result.data.role,
+ });
+ }
+
+ return apiSuccess({ success: true }, 201);
+ } catch (error) {
+ return handleRouteError(error, "Failed to create share");
+ }
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, role, tenantId } = await requireSession();
+ const { id } = await params;
+
+ const dashboard = await requireShareAccess(
+ id,
+ userId,
+ role === "admin",
+ tenantId,
+ );
+ if (!dashboard) {
+ return notFound();
+ }
+
+ const { searchParams } = new URL(request.url);
+ const shareId = searchParams.get("shareId");
+
+ if (!shareId) {
+ return badRequest("shareId is required");
+ }
+
+ await db
+ .delete(dashboardShares)
+ .where(
+ and(
+ eq(dashboardShares.id, shareId),
+ eq(dashboardShares.dashboardId, id),
+ eq(dashboardShares.tenantId, tenantId),
+ ),
+ );
+
+ return apiSuccess({ success: true });
+ } catch (error) {
+ return handleRouteError(error, "Failed to delete share");
+ }
+}
diff --git a/app/src/app/api/dashboards/__tests__/route.test.ts b/app/src/app/api/dashboards/__tests__/route.test.ts
new file mode 100644
index 000000000..c6e7b97d7
--- /dev/null
+++ b/app/src/app/api/dashboards/__tests__/route.test.ts
@@ -0,0 +1,293 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeSelectChain, makeInsertChain } from "@/__tests__/helpers/drizzle-mocks";
+import { 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 mockDb = {
+ select: vi.fn(),
+ selectDistinctOn: vi.fn(),
+ insert: vi.fn(),
+};
+
+class UnauthorizedError extends Error {
+ constructor() {
+ super("Unauthorized");
+ }
+}
+class ForbiddenError extends Error {
+ constructor() {
+ super("Forbidden");
+ }
+}
+
+vi.mock("@/lib/auth/session", () => ({
+ requireSession: mockRequireSession,
+ requireUserId: vi.fn(),
+}));
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("GET /api/dashboards", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await GET(makeRequest({}, "http://localhost/api/dashboards"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns owned dashboards with role=owner in envelope (creator role)", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ const row = {
+ id: "d1", name: "My Dashboard", description: null, isPublic: false,
+ createdAt: new Date(), updatedAt: new Date(),
+ ownerId: "user-1", shareRole: null, updatedByName: null,
+ };
+ // Non-admin: 1) count, 2) selectDistinctOn
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 1 }]));
+ mockDb.selectDistinctOn.mockReturnValueOnce(makeSelectChain([row]));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/dashboards"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(1);
+ expect(body.data[0].role).toBe("owner");
+ expect(body.meta).toEqual({ total: 1, limit: 25, offset: 0 });
+ expect(body.error).toBeNull();
+ });
+
+ it("merges owned and shared dashboards (creator role)", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ const ownedRow = {
+ id: "d1", name: "Own", description: null, isPublic: false,
+ createdAt: new Date(), updatedAt: new Date(),
+ ownerId: "user-1", shareRole: null, updatedByName: null,
+ };
+ const sharedRow = {
+ id: "d2", name: "Shared", description: null, isPublic: false,
+ createdAt: new Date(), updatedAt: new Date(),
+ ownerId: "other-user", shareRole: "viewer", updatedByName: null,
+ };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 2 }]));
+ mockDb.selectDistinctOn.mockReturnValueOnce(makeSelectChain([ownedRow, sharedRow]));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/dashboards"));
+ const body = await res.json();
+ expect(body.data).toHaveLength(2);
+ expect(body.data.find((d: { id: string }) => d.id === "d1")?.role).toBe("owner");
+ expect(body.data.find((d: { id: string }) => d.id === "d2")?.role).toBe("viewer");
+ });
+
+ it("returns all tenant dashboards for admin role", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "admin-1", role: "admin", canWrite: true, tenantId: "default" });
+ const ownedRow = { id: "d1", name: "My Dashboard", description: null, isPublic: false, createdAt: new Date(), updatedAt: new Date(), ownerId: "admin-1" };
+ const otherRow = { id: "d2", name: "Other Dashboard", description: null, isPublic: false, createdAt: new Date(), updatedAt: new Date(), ownerId: "user-1" };
+ // Admin path: 1) count query, 2) paginated select
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([{ count: 2 }]))
+ .mockReturnValueOnce(makeSelectChain([ownedRow, otherRow]));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/dashboards"));
+ const body = await res.json();
+ expect(body.data).toHaveLength(2);
+ expect(body.data.find((d: { id: string }) => d.id === "d1")?.role).toBe("owner");
+ expect(body.data.find((d: { id: string }) => d.id === "d2")?.role).toBe("admin");
+ expect(body.meta.total).toBe(2);
+ });
+
+ it("returns only assigned dashboards for reader role", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "reader", canWrite: false, tenantId: "default" });
+ const assignedRow = {
+ id: "d1", name: "Assigned", description: null, isPublic: false,
+ createdAt: new Date(), updatedAt: new Date(),
+ ownerId: "other-user", shareRole: "viewer", updatedByName: null,
+ };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 1 }]));
+ mockDb.selectDistinctOn.mockReturnValueOnce(makeSelectChain([assignedRow]));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/dashboards"));
+ const body = await res.json();
+ expect(body.data).toHaveLength(1);
+ expect(body.data[0].id).toBe("d1");
+ });
+
+ it("includes public dashboards for creator role", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ const ownedRow = {
+ id: "d1", name: "Own", description: null, isPublic: false,
+ createdAt: new Date(), updatedAt: new Date(),
+ ownerId: "user-1", shareRole: null, updatedByName: null,
+ };
+ const publicRow = {
+ id: "d2", name: "Public Demo", description: null, isPublic: true,
+ createdAt: new Date(), updatedAt: new Date(),
+ ownerId: "other-user", shareRole: null, updatedByName: null,
+ };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 2 }]));
+ mockDb.selectDistinctOn.mockReturnValueOnce(makeSelectChain([ownedRow, publicRow]));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/dashboards"));
+ const body = await res.json();
+ expect(body.data).toHaveLength(2);
+ expect(body.data.find((d: { id: string }) => d.id === "d1")?.role).toBe("owner");
+ expect(body.data.find((d: { id: string }) => d.id === "d2")?.role).toBe("viewer");
+ });
+
+ it("deduplication handled by DB DISTINCT ON", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ // DB returns only 1 row (DISTINCT ON deduplicates at DB level)
+ const row = {
+ id: "d1", name: "Own", description: null, isPublic: true,
+ createdAt: new Date(), updatedAt: new Date(),
+ ownerId: "user-1", shareRole: null, updatedByName: null,
+ };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 1 }]));
+ mockDb.selectDistinctOn.mockReturnValueOnce(makeSelectChain([row]));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/dashboards"));
+ const body = await res.json();
+ expect(body.data).toHaveLength(1);
+ expect(body.data[0].id).toBe("d1");
+ });
+
+ it("includes public dashboards for reader role", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "reader", canWrite: false, tenantId: "default" });
+ const publicRow = {
+ id: "d1", name: "Public Demo", description: null, isPublic: true,
+ createdAt: new Date(), updatedAt: new Date(),
+ ownerId: "other-user", shareRole: null, updatedByName: null,
+ };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 1 }]));
+ mockDb.selectDistinctOn.mockReturnValueOnce(makeSelectChain([publicRow]));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/dashboards"));
+ const body = await res.json();
+ expect(body.data).toHaveLength(1);
+ expect(body.data[0].id).toBe("d1");
+ expect(body.data[0].role).toBe("viewer");
+ });
+});
+
+describe("POST /api/dashboards", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request) => Promise;
+
+ 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({ name: "DB" }));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 for reader role", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "reader", canWrite: false, tenantId: "default" });
+ const res = await POST(makeRequest({ name: "DB" }));
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 400 when name is missing", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ const res = await POST(makeRequest({}));
+ expect(res.status).toBe(400);
+ });
+
+ it("creates a dashboard and returns 201 envelope", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ const created = { id: "d1", name: "My Dashboard", userId: "user-1", createdAt: new Date() };
+ mockDb.insert.mockReturnValue(makeInsertChain([created]));
+
+ const res = await POST(makeRequest({ name: "My Dashboard" }));
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data).toEqual(created);
+ expect(body.error).toBeNull();
+ });
+
+ it("sets updatedBy to session userId on create", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ const created = { id: "d1", name: "Test", userId: "user-1", updatedBy: "user-1" };
+ const valuesSpy = vi.fn();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const chain: any = {
+ values: (...args: unknown[]) => { valuesSpy(...args); return chain; },
+ returning: () => Promise.resolve([created]),
+ };
+ mockDb.insert.mockReturnValue(chain);
+
+ const res = await POST(makeRequest({ name: "Test" }));
+ expect(res.status).toBe(201);
+ expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({ updatedBy: "user-1" }));
+ });
+});
+
+describe("GET /api/dashboards — updatedByName", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns updatedByName from joined user for admin", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "admin-1", role: "admin", canWrite: true, tenantId: "default" });
+ const row = {
+ id: "d1", name: "Dashboard", description: null, isPublic: false,
+ createdAt: new Date(), updatedAt: new Date(),
+ ownerId: "admin-1", updatedByName: "Alice",
+ };
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([{ count: 1 }]))
+ .mockReturnValueOnce(makeSelectChain([row]));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/dashboards"));
+ expect(res.status).toBe(200);
+ const body = res._body as { data: Array<{ updatedByName: string | null }> };
+ expect(body.data[0].updatedByName).toBe("Alice");
+ });
+
+ it("returns updatedByName as null when no updater", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ const row = {
+ id: "d1", name: "Dashboard", description: null, isPublic: false,
+ createdAt: new Date(), updatedAt: new Date(),
+ ownerId: "user-1", shareRole: null, updatedByName: null,
+ };
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 1 }]));
+ mockDb.selectDistinctOn.mockReturnValueOnce(makeSelectChain([row]));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/dashboards"));
+ expect(res.status).toBe(200);
+ const body = res._body as { data: Array<{ updatedByName: string | null }> };
+ expect(body.data[0].updatedByName).toBeNull();
+ });
+});
diff --git a/app/src/app/api/dashboards/import/__tests__/route.test.ts b/app/src/app/api/dashboards/import/__tests__/route.test.ts
new file mode 100644
index 000000000..9cc71f910
--- /dev/null
+++ b/app/src/app/api/dashboards/import/__tests__/route.test.ts
@@ -0,0 +1,181 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeSelectChain, makeInsertChain } from "@/__tests__/helpers/drizzle-mocks";
+import { 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 mockDb = {
+ select: vi.fn(),
+ insert: 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("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const SESSION = { userId: "user-1", role: "creator", canWrite: true, tenantId: "tenant-1" };
+
+const VALID_PAYLOAD = {
+ formatVersion: 1,
+ exportedAt: "2024-01-01T00:00:00.000Z",
+ dashboard: { name: "Imported Dashboard", description: null },
+ connections: {
+ conn_0: { name: "Neo4j Prod", type: "neo4j" },
+ },
+ layout: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ { id: "w1", chartType: "bar", connectionId: "conn_0", query: "MATCH (n) RETURN n" },
+ ],
+ gridLayout: [{ i: "w1", x: 0, y: 0, w: 6, h: 4 }],
+ },
+ ],
+ },
+};
+
+const NEODASH_PAYLOAD = {
+ title: "NeoDash Dashboard",
+ version: "2.4",
+ pages: [
+ {
+ title: "Page 1",
+ reports: [
+ {
+ id: "r1",
+ title: "My Table",
+ type: "table",
+ query: "MATCH (n) RETURN n",
+ x: 0,
+ y: 0,
+ width: 6,
+ height: 4,
+ settings: {},
+ parameters: {},
+ },
+ ],
+ },
+ ],
+};
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("POST /api/dashboards/import", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request) => Promise;
+
+ 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({ payload: VALID_PAYLOAD, connectionMapping: {} }));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 for reader role", async () => {
+ mockRequireSession.mockResolvedValue({ ...SESSION, role: "reader", canWrite: false });
+ const res = await POST(makeRequest({ payload: VALID_PAYLOAD, connectionMapping: {} }));
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 400 for invalid payload (missing formatVersion)", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { formatVersion: _fv, ...noVersion } = VALID_PAYLOAD;
+ const res = await POST(makeRequest({ payload: noVersion, connectionMapping: {} }));
+ expect(res.status).toBe(400);
+ });
+
+ it("imports a valid NeoBoard export and returns 201", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ // Connection ownership check returns 1 allowed connection
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ id: "real-conn-id" }]));
+ // No existing dashboard with same name
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+ const created = {
+ id: "new-dash",
+ name: "Imported Dashboard",
+ userId: "user-1",
+ tenantId: "tenant-1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockDb.insert.mockReturnValue(makeInsertChain([created]));
+
+ const res = await POST(
+ makeRequest({ payload: VALID_PAYLOAD, connectionMapping: { conn_0: "real-conn-id" } })
+ );
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data).toMatchObject({ id: "new-dash" });
+ });
+
+ it("auto-converts NeoDash format and returns 201", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ mockDb.select.mockReturnValueOnce(makeSelectChain([]));
+ const created = { id: "nd-dash", name: "NeoDash Dashboard", userId: "user-1", tenantId: "tenant-1", createdAt: new Date(), updatedAt: new Date() };
+ mockDb.insert.mockReturnValue(makeInsertChain([created]));
+
+ const res = await POST(makeRequest({ payload: NEODASH_PAYLOAD, connectionMapping: {} }));
+ expect(res.status).toBe(201);
+ });
+
+ it("appends (imported) to name when dashboard with same name already exists", async () => {
+ mockRequireSession.mockResolvedValue(SESSION);
+ // Connection ownership check returns 1 allowed connection
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ id: "real-conn-id" }]));
+ // Existing dashboard found
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ id: "existing" }]));
+ const created = {
+ id: "new-dash",
+ name: "Imported Dashboard (imported)",
+ userId: "user-1",
+ tenantId: "tenant-1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockDb.insert.mockReturnValue(makeInsertChain([created]));
+
+ const res = await POST(
+ makeRequest({ payload: VALID_PAYLOAD, connectionMapping: { conn_0: "real-conn-id" } })
+ );
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data.name).toContain("(imported)");
+ });
+});
diff --git a/app/src/app/api/dashboards/import/route.ts b/app/src/app/api/dashboards/import/route.ts
new file mode 100644
index 000000000..3478a55f6
--- /dev/null
+++ b/app/src/app/api/dashboards/import/route.ts
@@ -0,0 +1,105 @@
+import { z } from "zod";
+import { and, eq, inArray } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { connections, dashboards } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import {
+ neoboardExportSchema,
+ applyConnectionMapping,
+} from "@/lib/dashboard/dashboard-import";
+import {
+ isNeoDashFormat,
+ convertNeoDash,
+} from "@/lib/dashboard/neodash-converter";
+import type { DashboardLayoutV2 } from "@/lib/db/schema";
+import { forbidden, badRequest, handleRouteError } from "@/lib/api/api-utils";
+import { apiSuccess } from "@/lib/api/api-response";
+
+const importRequestSchema = z.object({
+ payload: z.unknown(),
+ connectionMapping: z.record(z.string()).default({}),
+});
+
+export async function POST(request: Request) {
+ try {
+ const { userId, tenantId, canWrite } = await requireSession();
+
+ if (!canWrite) {
+ return forbidden();
+ }
+
+ const parsedBody = importRequestSchema.safeParse(await request.json());
+ if (!parsedBody.success) {
+ return badRequest(
+ parsedBody.error.errors[0]?.message ?? "Invalid request body",
+ );
+ }
+ const { payload, connectionMapping } = parsedBody.data;
+
+ // Auto-detect and convert NeoDash format
+ let exportData;
+ if (isNeoDashFormat(payload)) {
+ exportData = convertNeoDash(payload);
+ } else {
+ const parsed = neoboardExportSchema.safeParse(payload);
+ if (!parsed.success) {
+ return badRequest(parsed.error.errors[0].message);
+ }
+ exportData = parsed.data;
+ }
+
+ // Validate that all mapped connection IDs belong to the caller
+ const mappedIds = [
+ ...new Set(Object.values(connectionMapping).filter(Boolean)),
+ ];
+ if (mappedIds.length > 0) {
+ const allowed = await db
+ .select({ id: connections.id })
+ .from(connections)
+ .where(
+ and(
+ inArray(connections.id, mappedIds),
+ eq(connections.userId, userId),
+ ),
+ );
+ if (allowed.length !== mappedIds.length) {
+ return badRequest("Invalid connection mapping");
+ }
+ }
+
+ // Apply connection mapping to layout
+ const mappedLayout = applyConnectionMapping(
+ exportData.layout as DashboardLayoutV2,
+ connectionMapping,
+ );
+
+ // Determine final name — append "(imported)" only if name already exists
+ let name = exportData.dashboard.name;
+ const [existing] = await db
+ .select({ id: dashboards.id })
+ .from(dashboards)
+ .where(and(eq(dashboards.name, name), eq(dashboards.tenantId, tenantId)))
+ .limit(1);
+
+ if (existing) {
+ name = `${name} (imported)`;
+ }
+
+ const [created] = await db
+ .insert(dashboards)
+ .values({
+ userId,
+ tenantId,
+ name,
+ description: exportData.dashboard.description ?? null,
+ layoutJson: mappedLayout,
+ isPublic: false,
+ updatedBy: userId,
+ })
+ .returning();
+
+ return apiSuccess(created, 201);
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
diff --git a/app/src/app/api/dashboards/route.ts b/app/src/app/api/dashboards/route.ts
new file mode 100644
index 000000000..8c83ede89
--- /dev/null
+++ b/app/src/app/api/dashboards/route.ts
@@ -0,0 +1,195 @@
+import { z } from "zod";
+import { and, count, countDistinct, eq, or, sql } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { dashboards, dashboardShares, users } from "@/lib/db/schema";
+import type { DashboardLayoutV2 } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { validateBody, forbidden, handleRouteError } from "@/lib/api/api-utils";
+import { apiSuccess, apiList, parsePagination } from "@/lib/api/api-response";
+
+interface WidgetPreviewItem {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ chartType: string;
+ thumbnailUrl?: string;
+}
+
+function computePreview(
+ layout: DashboardLayoutV2 | null | undefined,
+ thumbnails?: Record | null,
+): WidgetPreviewItem[] {
+ if (!layout?.pages?.[0]) return [];
+ const page = layout.pages[0];
+ const typeMap = new Map(page.widgets.map((w) => [w.id, w.chartType]));
+ return page.gridLayout.map((g) => ({
+ x: g.x,
+ y: g.y,
+ w: g.w,
+ h: g.h,
+ chartType: typeMap.get(g.i) ?? "unknown",
+ ...(thumbnails?.[g.i] ? { thumbnailUrl: thumbnails[g.i] } : {}),
+ }));
+}
+
+function countWidgets(layout: DashboardLayoutV2 | null | undefined): number {
+ if (!layout?.pages) return 0;
+ return layout.pages.reduce((sum, page) => sum + page.widgets.length, 0);
+}
+
+const createDashboardSchema = z.object({
+ name: z.string().min(1),
+ description: z.string().optional(),
+});
+
+export async function GET(request: Request) {
+ try {
+ const { userId, role, tenantId } = await requireSession();
+ const { limit, offset } = parsePagination(request);
+
+ if (role === "admin") {
+ // Admin sees every dashboard in the tenant — use DB-level pagination
+ // to avoid loading all dashboards into memory for large deployments.
+ const [{ count: total }] = await db
+ .select({ count: count() })
+ .from(dashboards)
+ .where(eq(dashboards.tenantId, tenantId));
+
+ const rows = await db
+ .select({
+ id: dashboards.id,
+ name: dashboards.name,
+ description: dashboards.description,
+ isPublic: dashboards.isPublic,
+ createdAt: dashboards.createdAt,
+ updatedAt: dashboards.updatedAt,
+ ownerId: dashboards.userId,
+ layoutJson: dashboards.layoutJson,
+ thumbnailJson: dashboards.thumbnailJson,
+ updatedByName: users.name,
+ })
+ .from(dashboards)
+ .leftJoin(users, eq(dashboards.updatedBy, users.id))
+ .where(eq(dashboards.tenantId, tenantId))
+ .orderBy(dashboards.updatedAt)
+ .limit(limit)
+ .offset(offset);
+
+ const mapped = rows.map((d) => {
+ const { layoutJson, thumbnailJson, ...rest } = d;
+ return {
+ ...rest,
+ role: d.ownerId === userId ? ("owner" as const) : ("admin" as const),
+ preview: computePreview(layoutJson, thumbnailJson),
+ widgetCount: countWidgets(layoutJson),
+ };
+ });
+
+ return apiList(mapped, { total: Number(total), limit, offset });
+ }
+
+ // Creator & Reader: single query with LEFT JOIN + OR for owned/shared/public.
+ // DB-level deduplication via DISTINCT ON, pagination via LIMIT/OFFSET.
+ const accessFilter =
+ role === "reader"
+ ? or(
+ sql`${dashboardShares.id} IS NOT NULL`,
+ eq(dashboards.isPublic, true),
+ )
+ : or(
+ eq(dashboards.userId, userId),
+ sql`${dashboardShares.id} IS NOT NULL`,
+ eq(dashboards.isPublic, true),
+ );
+
+ const [{ count: total }] = await db
+ .select({ count: countDistinct(dashboards.id) })
+ .from(dashboards)
+ .leftJoin(
+ dashboardShares,
+ and(
+ eq(dashboardShares.dashboardId, dashboards.id),
+ eq(dashboardShares.userId, userId),
+ eq(dashboardShares.tenantId, tenantId),
+ ),
+ )
+ .where(and(eq(dashboards.tenantId, tenantId), accessFilter));
+
+ const rows = await db
+ .selectDistinctOn([dashboards.id], {
+ id: dashboards.id,
+ name: dashboards.name,
+ description: dashboards.description,
+ isPublic: dashboards.isPublic,
+ createdAt: dashboards.createdAt,
+ updatedAt: dashboards.updatedAt,
+ ownerId: dashboards.userId,
+ shareRole: dashboardShares.role,
+ layoutJson: dashboards.layoutJson,
+ thumbnailJson: dashboards.thumbnailJson,
+ updatedByName: users.name,
+ })
+ .from(dashboards)
+ .leftJoin(
+ dashboardShares,
+ and(
+ eq(dashboardShares.dashboardId, dashboards.id),
+ eq(dashboardShares.userId, userId),
+ eq(dashboardShares.tenantId, tenantId),
+ ),
+ )
+ .leftJoin(users, eq(dashboards.updatedBy, users.id))
+ .where(and(eq(dashboards.tenantId, tenantId), accessFilter))
+ .orderBy(dashboards.id, dashboards.updatedAt)
+ .limit(limit)
+ .offset(offset);
+
+ const mapped = rows.map((d) => {
+ const { layoutJson, thumbnailJson, ownerId, shareRole, ...rest } = d;
+ const dashRole =
+ ownerId === userId
+ ? ("owner" as const)
+ : (shareRole ?? ("viewer" as const));
+ return {
+ ...rest,
+ role: dashRole,
+ preview: computePreview(layoutJson, thumbnailJson),
+ widgetCount: countWidgets(layoutJson),
+ };
+ });
+
+ return apiList(mapped, { total: Number(total), limit, offset });
+ } catch (error) {
+ return handleRouteError(error, "Failed to fetch dashboards");
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const { userId, canWrite, tenantId } = await requireSession();
+
+ if (!canWrite) {
+ return forbidden();
+ }
+
+ const body = await request.json();
+ const result = validateBody(createDashboardSchema, body);
+ if (!result.success) return result.response;
+
+ const [dashboard] = await db
+ .insert(dashboards)
+ .values({
+ userId,
+ tenantId,
+ name: result.data.name,
+ description: result.data.description,
+ updatedBy: userId,
+ })
+ .returning();
+
+ return apiSuccess(dashboard, 201);
+ } catch (error) {
+ return handleRouteError(error, "Failed to create dashboard");
+ }
+}
diff --git a/app/src/app/api/docs/__tests__/route.test.ts b/app/src/app/api/docs/__tests__/route.test.ts
new file mode 100644
index 000000000..8cbc9f65e
--- /dev/null
+++ b/app/src/app/api/docs/__tests__/route.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from "vitest";
+import { GET } from "../route";
+
+describe("GET /api/docs", () => {
+ it("returns 200", async () => {
+ const res = await GET();
+ expect(res.status).toBe(200);
+ });
+
+ it("returns HTML content-type", async () => {
+ const res = await GET();
+ expect(res.headers.get("content-type")).toMatch(/text\/html/);
+ });
+
+ it("includes Swagger UI CDN reference", async () => {
+ const res = await GET();
+ const body = await res.text();
+ expect(body).toContain("swagger-ui");
+ });
+
+ it("references the openapi.json spec", async () => {
+ const res = await GET();
+ const body = await res.text();
+ expect(body).toContain("/api/openapi.json");
+ });
+
+ it("includes a page title", async () => {
+ const res = await GET();
+ const body = await res.text();
+ expect(body).toContain("");
+ expect(body).toContain("NeoBoard");
+ });
+});
diff --git a/app/src/app/api/docs/route.ts b/app/src/app/api/docs/route.ts
new file mode 100644
index 000000000..03d594c30
--- /dev/null
+++ b/app/src/app/api/docs/route.ts
@@ -0,0 +1,52 @@
+export const dynamic = "force-static";
+
+const SWAGGER_UI_VERSION = "5.18.2";
+
+const HTML = `
+
+
+
+
+ NeoBoard API Docs
+
+
+
+
+
+
+
+
+
+`;
+
+export function GET() {
+ return new Response(HTML, {
+ headers: {
+ "Content-Type": "text/html; charset=utf-8",
+ "Cache-Control": "public, max-age=3600",
+ },
+ });
+}
diff --git a/app/src/app/api/features/__tests__/route.test.ts b/app/src/app/api/features/__tests__/route.test.ts
new file mode 100644
index 000000000..04c9c4227
--- /dev/null
+++ b/app/src/app/api/features/__tests__/route.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+vi.mock("next/server", () => nextResponseMockFactory());
+
+describe("GET /api/features", () => {
+ const originalEdition = process.env.NEOBOARD_EDITION;
+
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ if (originalEdition === undefined) {
+ delete process.env.NEOBOARD_EDITION;
+ } else {
+ process.env.NEOBOARD_EDITION = originalEdition;
+ }
+ });
+
+ it("returns community edition with empty features when no env var set", async () => {
+ delete process.env.NEOBOARD_EDITION;
+ const { GET } = await import("../route");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.data.edition).toBe("community");
+ expect(json.data.features).toEqual([]);
+ });
+
+ it("returns enterprise edition with all features when env var is set", async () => {
+ process.env.NEOBOARD_EDITION = "enterprise";
+ const { GET } = await import("../route");
+ const res = await GET();
+ const json = await res.json();
+ expect(json.data.edition).toBe("enterprise");
+ expect(json.data.features).toContain("sso");
+ expect(json.data.features).toContain("custom-roles");
+ expect(json.data.features.length).toBeGreaterThanOrEqual(11);
+ });
+});
diff --git a/app/src/app/api/features/route.ts b/app/src/app/api/features/route.ts
new file mode 100644
index 000000000..f97fde42e
--- /dev/null
+++ b/app/src/app/api/features/route.ts
@@ -0,0 +1,16 @@
+import { apiSuccess } from "@/lib/api/api-response";
+import { getEdition, getEnabledFeatures } from "@/lib/features/registry";
+
+/**
+ * GET /api/features
+ *
+ * Public endpoint — returns the current edition and the list of enabled
+ * enterprise features. Clients use this to decide which UI to render and
+ * whether to show locked badges on gated features.
+ */
+export async function GET() {
+ return apiSuccess({
+ edition: getEdition(),
+ features: getEnabledFeatures(),
+ });
+}
diff --git a/app/src/app/api/health/__tests__/route.test.ts b/app/src/app/api/health/__tests__/route.test.ts
new file mode 100644
index 000000000..ae19e4291
--- /dev/null
+++ b/app/src/app/api/health/__tests__/route.test.ts
@@ -0,0 +1,139 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+const mockValidate = vi.fn();
+const mockDbExecute = vi.fn();
+
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/env-config", () => ({
+ validateEnvConfig: mockValidate,
+}));
+vi.mock("@/lib/db", () => ({
+ db: { execute: mockDbExecute },
+}));
+vi.mock("drizzle-orm", () => ({
+ sql: (strings: TemplateStringsArray) => strings[0],
+}));
+
+describe("GET /api/health", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: () => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ mockDbExecute.mockResolvedValue([{ "?column?": 1 }]);
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ vi.doMock("@/lib/env-config", () => ({
+ validateEnvConfig: mockValidate,
+ }));
+ vi.doMock("@/lib/db", () => ({
+ db: { execute: mockDbExecute },
+ }));
+ vi.doMock("drizzle-orm", () => ({
+ sql: (strings: TemplateStringsArray) => strings[0],
+ }));
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 200 with ok status when config is valid", async () => {
+ mockValidate.mockReturnValue({
+ status: "ok",
+ errors: [],
+ warnings: [],
+ config: { DATABASE_URL: "set" },
+ });
+ const res = await GET();
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.status).toBe("ok");
+ expect(body.data.config).toBeDefined();
+ });
+
+ it("returns 200 with degraded status when warnings exist", async () => {
+ mockValidate.mockReturnValue({
+ status: "degraded",
+ errors: [],
+ warnings: [{ key: "OIDC_CLIENT_ID", level: "warning", message: "..." }],
+ config: { DATABASE_URL: "set", OIDC_CLIENT_ID: "unset" },
+ });
+ const res = await GET();
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.status).toBe("degraded");
+ expect(body.data.warnings).toHaveLength(1);
+ });
+
+ it("returns 503 when required vars are missing", async () => {
+ mockValidate.mockReturnValue({
+ status: "error",
+ errors: [{ key: "DATABASE_URL", level: "error", message: "..." }],
+ warnings: [],
+ config: { DATABASE_URL: "unset" },
+ });
+ const res = await GET();
+ expect(res.status).toBe(503);
+ const body = await res.json();
+ expect(body.data.status).toBe("error");
+ expect(body.data.errors).toHaveLength(1);
+ });
+
+ it("never exposes actual env var values", async () => {
+ mockValidate.mockReturnValue({
+ status: "ok",
+ errors: [],
+ warnings: [],
+ config: { DATABASE_URL: "set", ENCRYPTION_KEY: "set" },
+ });
+ const res = await GET();
+ const body = await res.json();
+ const configValues = Object.values(body.data.config);
+ for (const val of configValues) {
+ expect(val).toMatch(/^(set|unset)$/);
+ }
+ });
+
+ it("includes db status when database is reachable", async () => {
+ mockValidate.mockReturnValue({
+ status: "ok",
+ errors: [],
+ warnings: [],
+ config: { DATABASE_URL: "set" },
+ });
+ mockDbExecute.mockResolvedValue([{ "?column?": 1 }]);
+ const res = await GET();
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.db.status).toBe("ok");
+ expect(typeof body.data.db.latencyMs).toBe("number");
+ expect(body.data.db.latencyMs).toBeGreaterThanOrEqual(0);
+ });
+
+ it("returns 503 when database is unreachable", async () => {
+ mockValidate.mockReturnValue({
+ status: "ok",
+ errors: [],
+ warnings: [],
+ config: { DATABASE_URL: "set" },
+ });
+ mockDbExecute.mockRejectedValue(new Error("ECONNREFUSED"));
+ const res = await GET();
+ expect(res.status).toBe(503);
+ const body = await res.json();
+ expect(body.data.db.status).toBe("error");
+ expect(body.data.status).toBe("error");
+ });
+
+ it("returns 503 when env config errors exist regardless of DB status", async () => {
+ mockValidate.mockReturnValue({
+ status: "error",
+ errors: [{ key: "ENCRYPTION_KEY", level: "error", message: "missing" }],
+ warnings: [],
+ config: { ENCRYPTION_KEY: "unset" },
+ });
+ mockDbExecute.mockResolvedValue([{ "?column?": 1 }]);
+ const res = await GET();
+ expect(res.status).toBe(503);
+ });
+});
diff --git a/app/src/app/api/health/route.ts b/app/src/app/api/health/route.ts
new file mode 100644
index 000000000..25bdc6fd0
--- /dev/null
+++ b/app/src/app/api/health/route.ts
@@ -0,0 +1,50 @@
+import { db } from "@/lib/db";
+import { validateEnvConfig } from "@/lib/env-config";
+import { sql } from "drizzle-orm";
+import { NextResponse } from "next/server";
+
+/**
+ * GET /api/health
+ *
+ * Returns environment config validation and database connectivity status.
+ * Reports which vars are set/unset (never actual values).
+ * Returns 200 for ok/degraded, 503 for error (missing required vars or DB unreachable).
+ */
+export async function GET() {
+ const result = validateEnvConfig();
+
+ let dbStatus: { status: "ok" | "error"; latencyMs: number } = {
+ status: "error",
+ latencyMs: -1,
+ };
+ try {
+ const start = performance.now();
+ await db.execute(sql`SELECT 1`);
+ dbStatus = {
+ status: "ok",
+ latencyMs: Math.round(performance.now() - start),
+ };
+ } catch {
+ dbStatus = { status: "error", latencyMs: -1 };
+ }
+
+ const envFailed = result.status === "error";
+ const dbFailed = dbStatus.status === "error";
+ const overallStatus = envFailed || dbFailed ? "error" : result.status;
+ const httpStatus = overallStatus === "error" ? 503 : 200;
+
+ return NextResponse.json(
+ {
+ data: {
+ status: overallStatus,
+ errors: result.errors,
+ warnings: result.warnings,
+ config: result.config,
+ db: dbStatus,
+ },
+ error: null,
+ meta: null,
+ },
+ { status: httpStatus },
+ );
+}
diff --git a/app/src/app/api/keys/[id]/__tests__/route.test.ts b/app/src/app/api/keys/[id]/__tests__/route.test.ts
new file mode 100644
index 000000000..aafe0ae95
--- /dev/null
+++ b/app/src/app/api/keys/[id]/__tests__/route.test.ts
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeDeleteChain } from "@/__tests__/helpers/drizzle-mocks";
+import { makeParams } from "@/__tests__/helpers/request-helpers";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireSession = vi.fn();
+
+const mockDb = {
+ delete: 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("next/server", () => nextResponseMockFactory());
+
+// ---------------------------------------------------------------------------
+// Tests — DELETE /api/keys/[id]
+// ---------------------------------------------------------------------------
+
+describe("DELETE /api/keys/[id]", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let DELETE: (req: Request, ctx: { params: Promise<{ id: string }> }) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({ requireSession: mockRequireSession }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+ const mod = await import("../route");
+ DELETE = mod.DELETE;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await DELETE({} as Request, makeParams("key-1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 when user lacks canWrite permission", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "reader",
+ canWrite: false,
+ });
+ const res = await DELETE({} as Request, makeParams("key-1"));
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 404 when key doesn't exist", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ // No rows deleted
+ mockDb.delete.mockReturnValue(makeDeleteChain([]));
+ const res = await DELETE({} as Request, makeParams("nonexistent-key"));
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 404 when key belongs to different user", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ // Simulate that delete filtered by userId+tenantId found nothing
+ mockDb.delete.mockReturnValue(makeDeleteChain([]));
+ const res = await DELETE({} as Request, makeParams("other-users-key"));
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 200 and deletes key on valid request", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ mockDb.delete.mockReturnValue(makeDeleteChain([{ id: "key-1" }]));
+ const res = await DELETE({} as Request, makeParams("key-1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toMatchObject({ success: true });
+ });
+});
diff --git a/app/src/app/api/keys/[id]/route.ts b/app/src/app/api/keys/[id]/route.ts
new file mode 100644
index 000000000..99c32a170
--- /dev/null
+++ b/app/src/app/api/keys/[id]/route.ts
@@ -0,0 +1,42 @@
+import { NextResponse } from "next/server";
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { apiKeys } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { handleRouteError, notFound } from "@/lib/api/api-utils";
+
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, tenantId, canWrite } = await requireSession();
+ if (!canWrite) {
+ throw new Error("Forbidden");
+ }
+ const { id } = await params;
+
+ const deleted = await db
+ .delete(apiKeys)
+ .where(
+ and(
+ eq(apiKeys.id, id),
+ eq(apiKeys.userId, userId),
+ eq(apiKeys.tenantId, tenantId),
+ ),
+ )
+ .returning({ id: apiKeys.id });
+
+ if (deleted.length === 0) {
+ return notFound("API key not found");
+ }
+
+ return NextResponse.json({
+ data: { success: true },
+ error: null,
+ meta: null,
+ });
+ } catch (e) {
+ return handleRouteError(e, "Failed to revoke API key");
+ }
+}
diff --git a/app/src/app/api/keys/__tests__/route.test.ts b/app/src/app/api/keys/__tests__/route.test.ts
new file mode 100644
index 000000000..139ba3238
--- /dev/null
+++ b/app/src/app/api/keys/__tests__/route.test.ts
@@ -0,0 +1,412 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ makeSelectChain,
+ makeInsertChain,
+} from "@/__tests__/helpers/drizzle-mocks";
+import { makeRequest } from "@/__tests__/helpers/request-helpers";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireSession = vi.fn();
+const mockGenerateApiKey = vi.fn(() => ({
+ plaintext: "nb_" + "a".repeat(64),
+ hash: "hash_" + "a".repeat(59),
+}));
+
+const mockDb = {
+ select: vi.fn(),
+ insert: 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/auth/api-key", () => ({ generateApiKey: mockGenerateApiKey }));
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("next/server", () => nextResponseMockFactory());
+
+// ---------------------------------------------------------------------------
+// Tests — GET /api/keys
+// ---------------------------------------------------------------------------
+
+describe("GET /api/keys", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({
+ requireSession: mockRequireSession,
+ }));
+ vi.doMock("@/lib/auth/api-key", () => ({
+ generateApiKey: mockGenerateApiKey,
+ }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await GET(makeRequest(null));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns empty array when user has no keys", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await GET(makeRequest(null));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual([]);
+ });
+
+ it("returns list of keys without keyHash field", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ // The route uses an explicit column select — keyHash is never fetched from the DB.
+ // Mock reflects what Drizzle would actually return (only the requested columns).
+ const rows = [
+ {
+ id: "key-1",
+ name: "CI Key",
+ lastUsedAt: null,
+ expiresAt: null,
+ createdAt: new Date("2026-01-01"),
+ },
+ ];
+ mockDb.select.mockReturnValue(makeSelectChain(rows));
+ const res = await GET(makeRequest(null));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(1);
+ expect(body.data[0]).not.toHaveProperty("keyHash");
+ expect(body.data[0].name).toBe("CI Key");
+ });
+
+ it("only returns keys for the authenticated user (tenant-scoped)", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-2",
+ tenantId: "tenant-x",
+ role: "creator",
+ canWrite: true,
+ });
+
+ // Capture the where() argument to verify tenant scoping
+ let whereCalled = false;
+ const chainWithSpy = {
+ from: () => chainWithSpy,
+ where: (...args: unknown[]) => {
+ whereCalled = true;
+ // Drizzle passes an SQL expression — verify it was called at all
+ expect(args.length).toBeGreaterThan(0);
+ return Promise.resolve([]);
+ },
+ innerJoin: () => chainWithSpy,
+ leftJoin: () => chainWithSpy,
+ then: (fn: (v: unknown[]) => void) => Promise.resolve([]).then(fn),
+ };
+ mockDb.select.mockReturnValue(chainWithSpy);
+
+ await GET(makeRequest(null));
+ expect(mockDb.select).toHaveBeenCalled();
+ expect(whereCalled).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests — POST /api/keys
+// ---------------------------------------------------------------------------
+
+describe("POST /api/keys", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ mockGenerateApiKey.mockReturnValue({
+ plaintext: "nb_" + "a".repeat(64),
+ hash: "hash_" + "a".repeat(59),
+ });
+ vi.doMock("@/lib/auth/session", () => ({
+ requireSession: mockRequireSession,
+ }));
+ vi.doMock("@/lib/auth/api-key", () => ({
+ generateApiKey: mockGenerateApiKey,
+ }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+ const mod = await import("../route");
+ POST = mod.POST;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await POST(makeRequest({ name: "Test" }));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 when user lacks canWrite permission", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "reader",
+ canWrite: false,
+ });
+ const res = await POST(makeRequest({ name: "Test" }));
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 400 when name is missing", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ const res = await POST(makeRequest({}));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when name is empty string", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ const res = await POST(makeRequest({ name: "" }));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 201 with generated key on valid request", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ const insertedRow = {
+ id: "new-key-id",
+ name: "My CI Key",
+ expiresAt: null,
+ createdAt: new Date(),
+ };
+ mockDb.insert.mockReturnValue(makeInsertChain([insertedRow]));
+ const res = await POST(makeRequest({ name: "My CI Key" }));
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data.name).toBe("My CI Key");
+ expect(body.data.key).toBe("nb_" + "a".repeat(64));
+ });
+
+ it("returned key starts with nb_ prefix", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ mockDb.insert.mockReturnValue(
+ makeInsertChain([
+ { id: "k1", name: "Key", expiresAt: null, createdAt: new Date() },
+ ]),
+ );
+ const res = await POST(makeRequest({ name: "Key" }));
+ const body = await res.json();
+ expect(body.data.key.startsWith("nb_")).toBe(true);
+ });
+
+ it("returns 201 with null expiresAt when not provided", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ mockDb.insert.mockReturnValue(
+ makeInsertChain([
+ { id: "k2", name: "Key", expiresAt: null, createdAt: new Date() },
+ ]),
+ );
+ const res = await POST(makeRequest({ name: "Key" }));
+ const body = await res.json();
+ expect(body.data.expiresAt).toBeNull();
+ });
+
+ it("stores the hash in DB (not plaintext)", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+
+ // Spy on the values passed to insert().values()
+ let capturedValues: Record | null = null;
+ const insertChain = {
+ values: (vals: Record) => {
+ capturedValues = vals;
+ return insertChain;
+ },
+ returning: () =>
+ Promise.resolve([
+ { id: "k3", name: "Key", expiresAt: null, createdAt: new Date() },
+ ]),
+ };
+ mockDb.insert.mockReturnValue(insertChain);
+
+ await POST(makeRequest({ name: "Key" }));
+
+ expect(capturedValues).not.toBeNull();
+ // The inserted row must contain keyHash (the hash), NOT the plaintext key
+ expect(capturedValues!.keyHash).toBe("hash_" + "a".repeat(59));
+ // Plaintext key must NOT be stored in the DB row
+ expect(capturedValues!).not.toHaveProperty("key");
+ expect(Object.values(capturedValues!)).not.toContain(
+ "nb_" + "a".repeat(64),
+ );
+ });
+
+ it("passes expiresAt as Date when provided", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+
+ let capturedValues: Record | null = null;
+ const insertChain = {
+ values: (vals: Record) => {
+ capturedValues = vals;
+ return insertChain;
+ },
+ returning: () =>
+ Promise.resolve([
+ {
+ id: "k5",
+ name: "Key",
+ expiresAt: "2027-01-01T00:00:00.000Z",
+ createdAt: new Date(),
+ },
+ ]),
+ };
+ mockDb.insert.mockReturnValue(insertChain);
+
+ const res = await POST(
+ makeRequest({ name: "Key", expiresAt: "2027-01-01T00:00:00.000Z" }),
+ );
+ expect(res.status).toBe(201);
+ expect(capturedValues).not.toBeNull();
+ expect(capturedValues!.expiresAt).toBeInstanceOf(Date);
+ });
+
+ it("stores tenantId from session", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "my-tenant",
+ role: "creator",
+ canWrite: true,
+ });
+
+ let capturedValues: Record | null = null;
+ const insertChain = {
+ values: (vals: Record) => {
+ capturedValues = vals;
+ return insertChain;
+ },
+ returning: () =>
+ Promise.resolve([
+ { id: "k4", name: "Key", expiresAt: null, createdAt: new Date() },
+ ]),
+ };
+ mockDb.insert.mockReturnValue(insertChain);
+
+ await POST(makeRequest({ name: "Key" }));
+
+ expect(capturedValues).not.toBeNull();
+ expect(capturedValues!.tenantId).toBe("my-tenant");
+ expect(capturedValues!.userId).toBe("user-1");
+ });
+
+ it("returns 503 with admin-specific message when generateApiKey throws and user is admin", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "admin",
+ canWrite: true,
+ });
+ mockGenerateApiKey.mockImplementation(() => {
+ throw new Error("API_KEY_HMAC_SECRET is not set");
+ });
+ const res = await POST(makeRequest({ name: "My Key" }));
+ expect(res.status).toBe(503);
+ const body = await res.json();
+ expect(body.error.code).toBe("SERVICE_UNAVAILABLE");
+ expect(body.error.message).toContain("API_KEY_HMAC_SECRET");
+ expect(body.error.message).toContain("environment variables");
+ });
+
+ it("returns 503 with generic message when generateApiKey throws and user is not admin", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ mockGenerateApiKey.mockImplementation(() => {
+ throw new Error("API_KEY_HMAC_SECRET is not set");
+ });
+ const res = await POST(makeRequest({ name: "My Key" }));
+ expect(res.status).toBe(503);
+ const body = await res.json();
+ expect(body.error.code).toBe("SERVICE_UNAVAILABLE");
+ expect(body.error.message).toContain("Contact your administrator");
+ expect(body.error.message).not.toContain("API_KEY_HMAC_SECRET");
+ });
+
+ it("returns 503 with generic message when generateApiKey throws and user is creator role", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ tenantId: "default",
+ role: "creator",
+ canWrite: true,
+ });
+ mockGenerateApiKey.mockImplementation(() => {
+ throw new Error("HMAC secret missing");
+ });
+ const res = await POST(makeRequest({ name: "My Key" }));
+ expect(res.status).toBe(503);
+ const body = await res.json();
+ expect(body.error.code).toBe("SERVICE_UNAVAILABLE");
+ expect(body.error.message).toBe(
+ "API key service is not available. Contact your administrator.",
+ );
+ });
+});
diff --git a/app/src/app/api/keys/route.ts b/app/src/app/api/keys/route.ts
new file mode 100644
index 000000000..73fd99531
--- /dev/null
+++ b/app/src/app/api/keys/route.ts
@@ -0,0 +1,89 @@
+import { z } from "zod";
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { apiKeys } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { generateApiKey } from "@/lib/auth/api-key";
+import { validateBody, forbidden, handleRouteError } from "@/lib/api/api-utils";
+import { apiSuccess } from "@/lib/api/api-response";
+
+const createKeySchema = z.object({
+ name: z.string().min(1, "Name is required"),
+ expiresAt: z.string().datetime().optional(),
+});
+
+export async function GET() {
+ try {
+ const { userId, tenantId } = await requireSession();
+
+ const rows = await db
+ .select({
+ id: apiKeys.id,
+ name: apiKeys.name,
+ lastUsedAt: apiKeys.lastUsedAt,
+ expiresAt: apiKeys.expiresAt,
+ createdAt: apiKeys.createdAt,
+ })
+ .from(apiKeys)
+ .where(and(eq(apiKeys.userId, userId), eq(apiKeys.tenantId, tenantId)));
+
+ return apiSuccess(rows);
+ } catch (e) {
+ return handleRouteError(e, "Failed to list API keys");
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const { userId, tenantId, canWrite, role } = await requireSession();
+ if (!canWrite) {
+ return forbidden();
+ }
+
+ const body = await request.json();
+ const validation = validateBody(createKeySchema, body);
+ if (!validation.success) return validation.response;
+
+ const { name, expiresAt } = validation.data;
+
+ let plaintext: string;
+ let hash: string;
+ try {
+ ({ plaintext, hash } = generateApiKey());
+ } catch {
+ // generateApiKey throws when API_KEY_HMAC_SECRET is missing
+ const msg =
+ role === "admin"
+ ? "API_KEY_HMAC_SECRET is not configured. Set it in your environment variables."
+ : "API key service is not available. Contact your administrator.";
+ return Response.json(
+ {
+ data: null,
+ error: { code: "SERVICE_UNAVAILABLE", message: msg },
+ meta: null,
+ },
+ { status: 503 },
+ );
+ }
+
+ const [inserted] = await db
+ .insert(apiKeys)
+ .values({
+ userId,
+ tenantId,
+ keyHash: hash,
+ name,
+ expiresAt: expiresAt ? new Date(expiresAt) : null,
+ })
+ .returning({
+ id: apiKeys.id,
+ name: apiKeys.name,
+ expiresAt: apiKeys.expiresAt,
+ createdAt: apiKeys.createdAt,
+ });
+
+ return apiSuccess({ ...inserted, key: plaintext }, 201);
+ } catch (e) {
+ return handleRouteError(e, "Failed to create API key");
+ }
+}
diff --git a/app/src/app/api/openapi.json/__tests__/route.test.ts b/app/src/app/api/openapi.json/__tests__/route.test.ts
new file mode 100644
index 000000000..442c7e979
--- /dev/null
+++ b/app/src/app/api/openapi.json/__tests__/route.test.ts
@@ -0,0 +1,61 @@
+import { describe, it, expect } from "vitest";
+import { GET } from "../route";
+
+describe("GET /api/openapi.json", () => {
+ it("returns 200", async () => {
+ const res = await GET();
+ expect(res.status).toBe(200);
+ });
+
+ it("returns JSON with OpenAPI 3.0 version", async () => {
+ const res = await GET();
+ const body = await res.json();
+ expect(body.openapi).toMatch(/^3\.0\./);
+ });
+
+ it("has info block with title and version", async () => {
+ const res = await GET();
+ const body = await res.json();
+ expect(body.info).toMatchObject({
+ title: expect.any(String),
+ version: expect.any(String),
+ });
+ });
+
+ it("has paths block covering key resources", async () => {
+ const res = await GET();
+ const body = await res.json();
+ expect(body.paths).toHaveProperty("/api/connections");
+ expect(body.paths).toHaveProperty("/api/dashboards");
+ expect(body.paths).toHaveProperty("/api/query");
+ expect(body.paths).toHaveProperty("/api/users");
+ expect(body.paths).toHaveProperty("/api/keys");
+ expect(body.paths).toHaveProperty("/api/keys/{id}");
+ });
+
+ it("has BearerAuth security scheme", async () => {
+ const res = await GET();
+ const body = await res.json();
+ expect(body.components.securitySchemes).toHaveProperty("BearerAuth");
+ expect(body.components.securitySchemes.BearerAuth).toMatchObject({
+ type: "http",
+ scheme: "bearer",
+ });
+ });
+
+ it("has CookieAuth security scheme", async () => {
+ const res = await GET();
+ const body = await res.json();
+ expect(body.components.securitySchemes).toHaveProperty("CookieAuth");
+ });
+
+ it("sets correct content-type header", async () => {
+ const res = await GET();
+ expect(res.headers.get("content-type")).toMatch(/application\/json/);
+ });
+
+ it("sets CORS header for public spec access", async () => {
+ const res = await GET();
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
+ });
+});
diff --git a/app/src/app/api/openapi.json/route.ts b/app/src/app/api/openapi.json/route.ts
new file mode 100644
index 000000000..83815993d
--- /dev/null
+++ b/app/src/app/api/openapi.json/route.ts
@@ -0,0 +1,13 @@
+import { NextResponse } from "next/server";
+import SPEC from "@/lib/api/openapi-spec";
+
+export const dynamic = "force-static";
+
+export function GET() {
+ return NextResponse.json(SPEC, {
+ headers: {
+ "Access-Control-Allow-Origin": "*",
+ "Cache-Control": "public, max-age=3600",
+ },
+ });
+}
diff --git a/app/src/app/api/openapi/__tests__/route.test.ts b/app/src/app/api/openapi/__tests__/route.test.ts
new file mode 100644
index 000000000..b877c229d
--- /dev/null
+++ b/app/src/app/api/openapi/__tests__/route.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect } from "vitest";
+import { GET } from "../route";
+
+describe("GET /api/openapi", () => {
+ it("returns 200 with valid OpenAPI 3.0 spec", async () => {
+ const res = await GET();
+ expect(res.status).toBe(200);
+
+ const body = await res.json();
+ expect(body.openapi).toBe("3.0.3");
+ expect(body.info.title).toBe("NeoBoard API");
+ });
+
+ it("includes all resource paths", async () => {
+ const res = await GET();
+ const body = await res.json();
+ const paths = Object.keys(body.paths);
+
+ expect(paths).toContain("/api/users");
+ expect(paths).toContain("/api/users/{id}");
+ expect(paths).toContain("/api/connections");
+ expect(paths).toContain("/api/connections/{id}");
+ expect(paths).toContain("/api/dashboards");
+ expect(paths).toContain("/api/dashboards/{id}");
+ expect(paths).toContain("/api/widget-templates");
+ expect(paths).toContain("/api/widget-templates/{id}");
+ expect(paths).toContain("/api/query");
+ expect(paths).toContain("/api/query/write");
+ });
+
+ it("documents all HTTP methods for user routes", async () => {
+ const res = await GET();
+ const body = await res.json();
+
+ expect(body.paths["/api/users"]).toHaveProperty("get");
+ expect(body.paths["/api/users"]).toHaveProperty("post");
+ expect(body.paths["/api/users/{id}"]).toHaveProperty("patch");
+ expect(body.paths["/api/users/{id}"]).toHaveProperty("delete");
+ });
+
+ it("includes security scheme", async () => {
+ const res = await GET();
+ const body = await res.json();
+
+ expect(body.components.securitySchemes.BearerAuth).toBeDefined();
+ expect(body.components.securitySchemes.BearerAuth.type).toBe("http");
+ expect(body.components.securitySchemes.BearerAuth.scheme).toBe("bearer");
+ });
+
+ it("sets Cache-Control header", async () => {
+ const res = await GET();
+ expect(res.headers.get("Cache-Control")).toBe("public, max-age=3600");
+ });
+});
diff --git a/app/src/app/api/openapi/route.ts b/app/src/app/api/openapi/route.ts
new file mode 100644
index 000000000..084dad418
--- /dev/null
+++ b/app/src/app/api/openapi/route.ts
@@ -0,0 +1,9 @@
+import { NextResponse } from "next/server";
+import openapiSpec from "@/lib/api/openapi-spec";
+
+/** Serves the OpenAPI 3.0 specification as JSON. */
+export async function GET() {
+ return NextResponse.json(openapiSpec, {
+ headers: { "Cache-Control": "public, max-age=3600" },
+ });
+}
diff --git a/app/src/app/api/query/__tests__/route.test.ts b/app/src/app/api/query/__tests__/route.test.ts
new file mode 100644
index 000000000..a7ddf91f9
--- /dev/null
+++ b/app/src/app/api/query/__tests__/route.test.ts
@@ -0,0 +1,739 @@
+// Coverage: verified at 100% for /api/query routes
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeRequest } from "@/__tests__/helpers/request-helpers";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks — must be declared before importing the route so Vitest hoists them.
+// ---------------------------------------------------------------------------
+
+const mockRequireSession = vi.fn<
+ () => Promise<{
+ userId: string;
+ tenantId: string;
+ role: string;
+ canWrite: boolean;
+ }>
+>();
+const mockDb = {
+ select: vi.fn(),
+ insert: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+};
+const mockDecryptJson = vi.fn();
+const mockExecuteQuery = 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/crypto/crypto", () => ({
+ decryptJson: mockDecryptJson,
+ encryptJson: vi.fn(),
+}));
+vi.mock("@/lib/query/query-executor", () => ({
+ executeQuery: mockExecuteQuery,
+}));
+vi.mock("@/lib/connector/schema-prefetch", () => ({ prefetchSchema: vi.fn() }));
+
+// Minimal Next.js server shim
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+/** Default authenticated session */
+const defaultSession = {
+ userId: "user-1",
+ tenantId: "tenant-a",
+ role: "creator",
+ canWrite: true,
+};
+
+// Chainable drizzle query builder stub that resolves to `rows`.
+function drizzleSelectChain(rows: unknown[]) {
+ const chain = {
+ from: () => chain,
+ where: () => chain,
+ limit: () => Promise.resolve(rows),
+ then: (resolve: (v: unknown[]) => unknown) =>
+ Promise.resolve(rows).then(resolve),
+ };
+ return chain;
+}
+
+// Like drizzleSelectChain but also supports leftJoin (for dashboard-share queries).
+function drizzleJoinChain(rows: unknown[]) {
+ const chain = {
+ from: () => chain,
+ leftJoin: () => chain,
+ where: () => chain,
+ limit: () => Promise.resolve(rows),
+ then: (resolve: (v: unknown[]) => unknown) =>
+ Promise.resolve(rows).then(resolve),
+ };
+ return chain;
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("POST /api/query", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request) => Promise;
+
+ 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({ connectionId: "c1", query: "MATCH (n) RETURN n" }),
+ );
+ // handleRouteError maps UnauthorizedError → 401
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.error.code).toBe("UNAUTHORIZED");
+ });
+
+ it("returns 400 for invalid body (missing query)", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ const res = await POST(makeRequest({ connectionId: "c1" }));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for invalid body (missing connectionId)", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ const res = await POST(makeRequest({ query: "SELECT 1" }));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when connection not found / not owned", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ // 1st call: ownership check -> not found
+ // 2nd call: dashboard-access check -> no access
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([]))
+ .mockReturnValueOnce(drizzleJoinChain([]));
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "SELECT 1" }),
+ );
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.message).toMatch(/not found/i);
+ });
+
+ it("returns 403 when body tenantId does not match session tenantId", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "SELECT 1",
+ tenantId: "tenant-b",
+ }),
+ );
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.message).toBe("Tenant mismatch");
+ });
+
+ it("succeeds when body tenantId matches session tenantId", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ {
+ id: "c1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ userId: "user-1",
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "postgres://localhost",
+ username: "u",
+ password: "p",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: [{ n: 1 }], fields: ["n"] });
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "SELECT 1",
+ tenantId: "tenant-a",
+ }),
+ );
+ expect(res.status).toBe(200);
+ });
+
+ it("returns 200 with resultId on happy path (no tenantId in body)", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ {
+ id: "c1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ userId: "user-1",
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "postgres://localhost",
+ username: "u",
+ password: "p",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: [{ n: 1 }], fields: ["n"] });
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "SELECT 1" }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.meta.resultId).toHaveLength(16);
+ expect(body.meta.resultId).toMatch(/^[0-9a-f]{16}$/);
+ expect(body.data.data).toEqual([{ n: 1 }]);
+ });
+
+ it("includes resultId in response and it matches computeResultId", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ { id: "c1", type: "neo4j", configEncrypted: "enc", userId: "user-1" },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: [], fields: [] });
+
+ const { computeResultId } = await import("@/lib/query/query-hash");
+ const expected = computeResultId("c1", "MATCH (n) RETURN n");
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "MATCH (n) RETURN n" }),
+ );
+ const body = await res.json();
+ expect(body.meta.resultId).toBe(expected);
+ });
+
+ it("returns 500 when executeQuery throws", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ { id: "c1", type: "neo4j", configEncrypted: "enc", userId: "user-1" },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ mockExecuteQuery.mockRejectedValue(new Error("Driver error"));
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "MATCH (n) RETURN n" }),
+ );
+ expect(res.status).toBe(500);
+ const body = await res.json();
+ // handleRouteError returns a generic fallback message to the client;
+ // the raw error is logged but never surfaced (avoids leaking schema).
+ expect(body.error.code).toBe("INTERNAL_ERROR");
+ expect(body.error.message).toBe("Driver error");
+ });
+
+ // --- Access fallback tests ---
+
+ it("admin can execute query on unowned connection", async () => {
+ mockRequireSession.mockResolvedValue({ ...defaultSession, role: "admin" });
+ const conn = {
+ id: "c1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ userId: "other-user",
+ };
+ // 1st call: ownership check -> not found
+ // 2nd call: admin fallback -> found
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([]))
+ .mockReturnValueOnce(drizzleSelectChain([conn]));
+ mockDecryptJson.mockReturnValue({
+ uri: "postgres://localhost",
+ username: "u",
+ password: "p",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: [{ n: 1 }], fields: ["n"] });
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "SELECT 1" }),
+ );
+ expect(res.status).toBe(200);
+ expect(mockDb.select).toHaveBeenCalledTimes(2);
+ });
+
+ it("non-admin with dashboard share can execute query on unowned connection", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...defaultSession,
+ role: "creator",
+ });
+ const conn = {
+ id: "c1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ userId: "other-user",
+ };
+ // 1st call: ownership check -> not found
+ // 2nd call: dashboard-access check (join) -> found a matching dashboard
+ // 3rd call: fetch the connection by id
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([]))
+ .mockReturnValueOnce(drizzleJoinChain([{ id: "d1" }]))
+ .mockReturnValueOnce(drizzleSelectChain([conn]));
+ mockDecryptJson.mockReturnValue({
+ uri: "postgres://localhost",
+ username: "u",
+ password: "p",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: [{ n: 1 }], fields: ["n"] });
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "SELECT 1" }),
+ );
+ expect(res.status).toBe(200);
+ expect(mockDb.select).toHaveBeenCalledTimes(3);
+ });
+
+ it("non-admin without dashboard access gets 404", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...defaultSession,
+ role: "creator",
+ });
+ // 1st call: ownership check -> not found
+ // 2nd call: dashboard-access check -> no matching dashboard
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([]))
+ .mockReturnValueOnce(drizzleJoinChain([]));
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "SELECT 1" }),
+ );
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.message).toMatch(/not found/i);
+ });
+
+ it("non-admin with public dashboard can execute query on unowned connection", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...defaultSession,
+ role: "creator",
+ });
+ const conn = {
+ id: "c1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ userId: "other-user",
+ };
+ // 1st call: ownership check -> not found
+ // 2nd call: dashboard-access check (join) -> found a public dashboard
+ // 3rd call: fetch the connection by id
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([]))
+ .mockReturnValueOnce(drizzleJoinChain([{ id: "d1" }]))
+ .mockReturnValueOnce(drizzleSelectChain([conn]));
+ mockDecryptJson.mockReturnValue({
+ uri: "postgres://localhost",
+ username: "u",
+ password: "p",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: [{ n: 1 }], fields: ["n"] });
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "SELECT 1" }),
+ );
+ expect(res.status).toBe(200);
+ expect(mockDb.select).toHaveBeenCalledTimes(3);
+ });
+
+ it("owner still works without any fallback (regression)", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ const conn = {
+ id: "c1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ userId: "user-1",
+ };
+ mockDb.select.mockReturnValueOnce(drizzleSelectChain([conn]));
+ mockDecryptJson.mockReturnValue({
+ uri: "postgres://localhost",
+ username: "u",
+ password: "p",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: [{ n: 1 }], fields: ["n"] });
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "SELECT 1" }),
+ );
+ expect(res.status).toBe(200);
+ // Only 1 db.select call — fast path, no fallback needed
+ expect(mockDb.select).toHaveBeenCalledTimes(1);
+ });
+
+ // --- Tenant isolation tests ---
+
+ it("fast-path ownership check is tenant-scoped (regression: #572)", async () => {
+ // Simulate a connection that matches userId but belongs to a different
+ // tenant. The fast-path WHERE clause must include tenantId so this
+ // connection is NOT returned.
+ mockRequireSession.mockResolvedValue(defaultSession);
+
+ // We need the where() call to actually filter by tenantId.
+ // Use a custom chain that inspects the call count to verify
+ // the fast-path returns empty (forcing fallback path → 404).
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([])) // fast-path: no match (tenant-scoped)
+ .mockReturnValueOnce(drizzleJoinChain([])); // dashboard-access: no match
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "SELECT 1" }),
+ );
+ // Connection exists for this userId but wrong tenant → 404
+ expect(res.status).toBe(404);
+ });
+
+ // --- Row cap (driver-reported truncation) tests ---
+ //
+ // Truncation is now enforced at the driver layer and signaled via the
+ // executor's setStatus callback. The route just forwards `truncated` and
+ // `rowLimit` from executeQuery's return value into the response meta —
+ // no more post-hoc `rawData.length > MAX_ROWS` slicing.
+
+ it("forwards truncated:true and rowLimit when the driver signals truncation", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ {
+ id: "c1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ userId: "user-1",
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "postgres://localhost",
+ username: "u",
+ password: "p",
+ });
+ // Driver already sliced to exactly rowLimit rows + set truncated flag.
+ const cappedData = Array.from({ length: 5000 }, (_, i) => ({ n: i }));
+ mockExecuteQuery.mockResolvedValue({
+ data: cappedData,
+ fields: ["n"],
+ truncated: true,
+ rowLimit: 5000,
+ });
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "SELECT * FROM t" }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.data).toHaveLength(5000);
+ expect(body.meta.truncated).toBe(true);
+ expect(body.meta.rowLimit).toBe(5000);
+ });
+
+ it("omits truncated flag when driver reports no truncation", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ {
+ id: "c1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ userId: "user-1",
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "postgres://localhost",
+ username: "u",
+ password: "p",
+ });
+ mockExecuteQuery.mockResolvedValue({
+ data: [{ n: 1 }],
+ fields: ["n"],
+ truncated: false,
+ rowLimit: 5000,
+ });
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "SELECT 1" }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.data).toHaveLength(1);
+ expect(body.meta.truncated).toBeUndefined();
+ expect(body.meta.rowLimit).toBe(5000);
+ });
+
+ it("echoes the per-connection rowLimit override when the creator raised it", async () => {
+ // When a connection's credentials.maxRows is set to e.g. 20000, the
+ // executor uses that as rowLimit and returns it in the result. This
+ // test pins that the route faithfully forwards the override.
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ {
+ id: "c1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ userId: "user-1",
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "postgres://localhost",
+ username: "u",
+ password: "p",
+ maxRows: 20000,
+ });
+ const cappedData = Array.from({ length: 20000 }, (_, i) => ({ n: i }));
+ mockExecuteQuery.mockResolvedValue({
+ data: cappedData,
+ fields: ["n"],
+ truncated: true,
+ rowLimit: 20000,
+ });
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "SELECT * FROM t" }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.data).toHaveLength(20000);
+ expect(body.meta.truncated).toBe(true);
+ expect(body.meta.rowLimit).toBe(20000);
+ });
+
+ it("forwards truncated correctly for non-array (graph) results", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ { id: "c1", type: "neo4j", configEncrypted: "enc", userId: "user-1" },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ // Non-array result (e.g. graph data object) — still carries a
+ // rowLimit in meta, but not truncated since the driver didn't flag it.
+ mockExecuteQuery.mockResolvedValue({
+ data: { nodes: [], edges: [] },
+ fields: [],
+ truncated: false,
+ rowLimit: 5000,
+ });
+
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "MATCH (n) RETURN n" }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.meta.truncated).toBeUndefined();
+ expect(body.meta.rowLimit).toBe(5000);
+ expect(body.data.data).toEqual({ nodes: [], edges: [] });
+ });
+
+ // --- Per-card database override tests ---
+
+ it("applies databaseOverride when connection.allowPerCardDb is true", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ {
+ id: "c1",
+ type: "neo4j",
+ configEncrypted: "enc",
+ userId: "user-1",
+ allowPerCardDb: true,
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ database: "neo4j",
+ });
+ mockExecuteQuery.mockResolvedValue({
+ data: [{ n: 1 }],
+ fields: ["n"],
+ truncated: false,
+ rowLimit: 5000,
+ });
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "MATCH (n) RETURN n",
+ database: "otherdb",
+ }),
+ );
+ expect(res.status).toBe(200);
+
+ // executeQuery should have been called with credentials where database is overridden
+ expect(mockExecuteQuery).toHaveBeenCalledWith(
+ "neo4j",
+ expect.objectContaining({ database: "otherdb" }),
+ expect.anything(),
+ );
+ });
+
+ it("ignores databaseOverride when connection.allowPerCardDb is false", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ {
+ id: "c1",
+ type: "neo4j",
+ configEncrypted: "enc",
+ userId: "user-1",
+ allowPerCardDb: false,
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ database: "neo4j",
+ });
+ mockExecuteQuery.mockResolvedValue({
+ data: [{ n: 1 }],
+ fields: ["n"],
+ truncated: false,
+ rowLimit: 5000,
+ });
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "MATCH (n) RETURN n",
+ database: "otherdb",
+ }),
+ );
+ expect(res.status).toBe(200);
+
+ // executeQuery should have been called with the original credentials (database NOT overridden)
+ expect(mockExecuteQuery).toHaveBeenCalledWith(
+ "neo4j",
+ expect.objectContaining({ database: "neo4j" }),
+ expect.anything(),
+ );
+ });
+
+ it("ignores databaseOverride when connection.allowPerCardDb is undefined", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ {
+ id: "c1",
+ type: "postgresql",
+ configEncrypted: "enc",
+ userId: "user-1",
+ // allowPerCardDb not set
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "postgres://localhost",
+ username: "u",
+ password: "p",
+ database: "mydb",
+ });
+ mockExecuteQuery.mockResolvedValue({
+ data: [{ n: 1 }],
+ fields: ["n"],
+ truncated: false,
+ rowLimit: 5000,
+ });
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "SELECT 1",
+ database: "hackerdb",
+ }),
+ );
+ expect(res.status).toBe(200);
+
+ // Original database preserved — override ignored
+ expect(mockExecuteQuery).toHaveBeenCalledWith(
+ "postgresql",
+ expect.objectContaining({ database: "mydb" }),
+ expect.anything(),
+ );
+ });
+
+ it("does not override when no database field is sent in body", async () => {
+ mockRequireSession.mockResolvedValue(defaultSession);
+ mockDb.select.mockReturnValue(
+ drizzleSelectChain([
+ {
+ id: "c1",
+ type: "neo4j",
+ configEncrypted: "enc",
+ userId: "user-1",
+ allowPerCardDb: true,
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ database: "neo4j",
+ });
+ mockExecuteQuery.mockResolvedValue({
+ data: [],
+ fields: [],
+ truncated: false,
+ rowLimit: 5000,
+ });
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "MATCH (n) RETURN n",
+ // no database field
+ }),
+ );
+ expect(res.status).toBe(200);
+
+ // Original credentials used as-is
+ expect(mockExecuteQuery).toHaveBeenCalledWith(
+ "neo4j",
+ expect.objectContaining({ database: "neo4j" }),
+ expect.anything(),
+ );
+ });
+});
diff --git a/app/src/app/api/query/route.ts b/app/src/app/api/query/route.ts
new file mode 100644
index 000000000..50950e2c2
--- /dev/null
+++ b/app/src/app/api/query/route.ts
@@ -0,0 +1,223 @@
+import { z } from "zod";
+import { and, eq, or, sql } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { connections, dashboards, dashboardShares } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { decryptJson } from "@/lib/crypto/crypto";
+import { executeQuery } from "@/lib/query/query-executor";
+import type { ConnectionCredentials, DbType } from "@/lib/query/query-executor";
+import { computeResultId } from "@/lib/query/query-hash";
+import { runPipeline } from "@/lib/query/pipeline";
+import type { QueryContext } from "@/lib/query/pipeline-types";
+import {
+ validateBody,
+ forbidden,
+ notFound,
+ handleRouteError,
+} from "@/lib/api/api-utils";
+import { apiSuccess } from "@/lib/api/api-response";
+import { logRoute } from "@/lib/api/log-route";
+import type { QueryPriority } from "@/lib/query/scheduler";
+
+/**
+ * Parse the `x-query-priority` header into a valid priority tier.
+ * Invalid or missing values default to P2 (load) so the request
+ * behaves like a dashboard page load under the scheduler.
+ */
+function readPriorityHeader(raw: string | null): QueryPriority {
+ if (raw === "1" || raw === "2" || raw === "3") {
+ return Number.parseInt(raw, 10) as QueryPriority;
+ }
+ return 2;
+}
+
+const querySchema = z.object({
+ connectionId: z.string().min(1),
+ query: z.string().min(1),
+ params: z.record(z.unknown()).optional(),
+ /** Optional defense-in-depth field: when provided, must match the session tenant. */
+ tenantId: z.string().optional(),
+ /** Per-card database override — used when the connection allows per-card DB selection. */
+ database: z.string().optional(),
+});
+
+export async function POST(request: Request) {
+ return logRoute(request, "query", () => handleReadQuery(request));
+}
+
+async function handleReadQuery(request: Request): Promise {
+ try {
+ const { userId, tenantId: sessionTenantId, role } = await requireSession();
+ const requestId = request.headers.get("x-request-id") ?? undefined;
+ const priority = readPriorityHeader(
+ request.headers.get("x-query-priority"),
+ );
+ const body = await request.json();
+ const validation = validateBody(querySchema, body);
+ if (!validation.success) return validation.response;
+
+ const {
+ connectionId,
+ query,
+ params,
+ tenantId: bodyTenantId,
+ database: databaseOverride,
+ } = validation.data;
+
+ // Defense-in-depth: if the caller explicitly passes a tenantId,
+ // assert it matches the session to catch misconfigured clients early.
+ if (bodyTenantId && bodyTenantId !== sessionTenantId) {
+ return forbidden("Tenant mismatch");
+ }
+
+ // 1. Fast path: direct ownership (tenant-scoped)
+ let [connection] = await db
+ .select()
+ .from(connections)
+ .where(
+ and(
+ eq(connections.id, connectionId),
+ eq(connections.userId, userId),
+ eq(connections.tenantId, sessionTenantId),
+ ),
+ )
+ .limit(1);
+
+ // 2. Admin fallback: admin can use any connection in the same tenant.
+ if (!connection && role === "admin") {
+ [connection] = await db
+ .select()
+ .from(connections)
+ .where(
+ and(
+ eq(connections.id, connectionId),
+ eq(connections.tenantId, sessionTenantId),
+ ),
+ )
+ .limit(1);
+ }
+
+ // 3. Dashboard-access fallback: user owns or has a share for a dashboard
+ // that references this connectionId in its layout
+ if (!connection) {
+ const hasAccess = await userHasDashboardAccessToConnection(
+ userId,
+ connectionId,
+ sessionTenantId,
+ );
+ if (hasAccess) {
+ [connection] = await db
+ .select()
+ .from(connections)
+ .where(
+ and(
+ eq(connections.id, connectionId),
+ eq(connections.tenantId, sessionTenantId),
+ ),
+ )
+ .limit(1);
+ }
+ }
+
+ if (!connection) {
+ return notFound("Connection not found");
+ }
+
+ const credentials = decryptJson(
+ connection.configEncrypted,
+ );
+
+ // Apply per-card database override if the connection allows it
+ const effectiveCredentials =
+ databaseOverride && connection.allowPerCardDb
+ ? { ...credentials, database: databaseOverride }
+ : credentials;
+
+ const metadata: Record = { priority };
+ if (requestId) metadata.requestId = requestId;
+
+ const ctx: QueryContext = {
+ query,
+ params: params ?? {},
+ connectionId,
+ connectionType: connection.type as DbType,
+ userId,
+ tenantId: sessionTenantId,
+ accessMode: "read",
+ metadata,
+ };
+
+ const queryStart = performance.now();
+ const result = await runPipeline(ctx, async (pipelineCtx) =>
+ executeQuery(pipelineCtx.connectionType, effectiveCredentials, {
+ query: pipelineCtx.query,
+ params: pipelineCtx.params,
+ }),
+ );
+ const serverDurationMs = Math.round(performance.now() - queryStart);
+
+ // Deterministic query hash: same connection + normalized query + params
+ // → same resultId. Clients can use this to preserve state (e.g. graph
+ // exploration) across re-executions of the same query, and as a future
+ // cache key. Normalization handled inside computeResultId.
+ const resultId = computeResultId(connectionId, query, params);
+
+ // Truncation is enforced at the driver level (see
+ // lib/query/query-executor.ts — it spreads `rowLimit` onto the connector
+ // config and each connector slices at that value before calling
+ // onSuccess). The executor captures the `COMPLETE_TRUNCATED` signal via
+ // its setStatus handler and returns { truncated, rowLimit } alongside
+ // the data, so the route just forwards those fields to the client for
+ // the widget banner.
+ const { data, fields, truncated, rowLimit } = result;
+
+ return apiSuccess({ data, fields }, 200, {
+ resultId,
+ serverDurationMs,
+ rowLimit,
+ ...(truncated ? { truncated: true } : {}),
+ });
+ } catch (error) {
+ return handleRouteError(error, "Query execution failed");
+ }
+}
+
+/**
+ * Check if the user owns or has been shared a dashboard whose layout
+ * references the given connectionId. This grants query-execution access
+ * only — no credential exposure or connection editing.
+ */
+async function userHasDashboardAccessToConnection(
+ userId: string,
+ connectionId: string,
+ tenantId: string,
+): Promise {
+ const [result] = await db
+ .select({ id: dashboards.id })
+ .from(dashboards)
+ .leftJoin(
+ dashboardShares,
+ and(
+ eq(dashboardShares.dashboardId, dashboards.id),
+ eq(dashboardShares.userId, userId),
+ eq(dashboardShares.tenantId, tenantId),
+ ),
+ )
+ .where(
+ and(
+ eq(dashboards.tenantId, tenantId),
+ or(
+ eq(dashboards.userId, userId),
+ sql`${dashboardShares.id} IS NOT NULL`,
+ eq(dashboards.isPublic, true),
+ ),
+ sql`EXISTS (
+ SELECT 1 FROM jsonb_array_elements(${dashboards.layoutJson}->'pages') AS page,
+ jsonb_array_elements(page->'widgets') AS widget
+ WHERE widget->>'connectionId' = ${connectionId}
+ )`,
+ ),
+ )
+ .limit(1);
+ return !!result;
+}
diff --git a/app/src/app/api/query/write/__tests__/route.test.ts b/app/src/app/api/query/write/__tests__/route.test.ts
new file mode 100644
index 000000000..4ddb5d13a
--- /dev/null
+++ b/app/src/app/api/query/write/__tests__/route.test.ts
@@ -0,0 +1,672 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeRequest } from "@/__tests__/helpers/request-helpers";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks — must be declared before importing the route so Vitest hoists them.
+// ---------------------------------------------------------------------------
+
+const mockRequireSession = vi.fn<
+ () => Promise<{
+ userId: string;
+ tenantId: string;
+ role: string;
+ canWrite: boolean;
+ }>
+>();
+const mockDb = {
+ select: vi.fn(),
+ insert: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+};
+const mockDecryptJson = vi.fn();
+const mockExecuteQuery = 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/crypto/crypto", () => ({
+ decryptJson: mockDecryptJson,
+ encryptJson: vi.fn(),
+}));
+vi.mock("@/lib/query/query-executor", () => ({
+ executeQuery: mockExecuteQuery,
+}));
+
+// Minimal Next.js server shim
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+/** Chainable drizzle query builder stub that resolves to `rows`. */
+function drizzleSelectChain(rows: unknown[]) {
+ const chain = {
+ from: () => chain,
+ where: () => chain,
+ limit: () => Promise.resolve(rows),
+ };
+ return chain;
+}
+
+const writerSession = {
+ userId: "user-1",
+ tenantId: "tenant-a",
+ role: "creator",
+ canWrite: true,
+};
+const readerSession = {
+ userId: "user-2",
+ tenantId: "tenant-a",
+ role: "reader",
+ canWrite: false,
+};
+
+const fakeConnection = {
+ id: "c1",
+ type: "neo4j",
+ configEncrypted: "enc",
+ userId: "user-1",
+};
+
+const fakeDashboard = {
+ id: "d1",
+ tenantId: "tenant-a",
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ allowWrites: true,
+ },
+ ],
+ gridLayout: [],
+ },
+ ],
+ },
+};
+
+/** Sets up mocks for both connection + dashboard lookups. */
+function mockConnectionAndDashboard() {
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([fakeConnection]))
+ .mockReturnValueOnce(drizzleSelectChain([fakeDashboard]));
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("POST /api/query/write", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+
+ const mod = await import("../route");
+ POST = mod.POST;
+ });
+
+ it("returns 403 when canWrite is false (reader role)", async () => {
+ mockRequireSession.mockResolvedValue(readerSession);
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "CREATE (n:Test)" }),
+ );
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.message).toMatch(/write permission/i);
+ });
+
+ it("returns 401 when session retrieval fails with UnauthorizedError", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await POST(
+ makeRequest({ connectionId: "c1", query: "CREATE (n:Test)" }),
+ );
+ // handleRouteError maps UnauthorizedError → 401
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.error.code).toBe("UNAUTHORIZED");
+ });
+
+ it("returns 400 for missing connectionId", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ const res = await POST(makeRequest({ query: "CREATE (n:Test)" }));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for missing query", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ const res = await POST(makeRequest({ connectionId: "c1" }));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when connection not found", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockDb.select.mockReturnValue(drizzleSelectChain([]));
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.message).toMatch(/not found/i);
+ });
+
+ it("returns 200 on success and calls executeQuery with accessMode WRITE", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockConnectionAndDashboard();
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: { nodesCreated: 1 } });
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(200);
+
+ const body = await res.json();
+ expect(body.data).toEqual({ nodesCreated: 1 });
+ expect(typeof body.meta.serverDurationMs).toBe("number");
+
+ // Verify executeQuery was called with WRITE access mode.
+ // The route wraps executeQuery in the query middleware pipeline,
+ // which normalizes missing params to {} so middleware sees a
+ // consistent shape.
+ expect(mockExecuteQuery).toHaveBeenCalledWith(
+ "neo4j",
+ { uri: "bolt://localhost", username: "neo4j", password: "pass" },
+ { query: "CREATE (n:Test)", params: {} },
+ { accessMode: "WRITE" },
+ );
+ });
+
+ it("passes params correctly to executeQuery", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockConnectionAndDashboard();
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: { nodesCreated: 1 } });
+
+ const params = { param_name: "Alice", param_age: 30 };
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Person {name: $param_name, age: $param_age})",
+ params,
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(200);
+ expect(mockExecuteQuery).toHaveBeenCalledWith(
+ "neo4j",
+ expect.any(Object),
+ {
+ query: "CREATE (n:Person {name: $param_name, age: $param_age})",
+ params,
+ },
+ { accessMode: "WRITE" },
+ );
+ });
+
+ 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",
+ });
+ // 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: "THIS IS NOT VALID SQL",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(500);
+ const body = await res.json();
+ 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 () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockDb.select.mockReturnValue(drizzleSelectChain([]));
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 404 when connection belongs to a different tenant", async () => {
+ mockRequireSession.mockResolvedValue({
+ ...writerSession,
+ tenantId: "tenant-other",
+ });
+ mockDb.select.mockReturnValue(drizzleSelectChain([]));
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 403 when widget allowWrites is false", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ // First select: connection found. Second select: dashboard found.
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([fakeConnection]))
+ .mockReturnValueOnce(
+ drizzleSelectChain([
+ {
+ id: "d1",
+ tenantId: "tenant-a",
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ allowWrites: false,
+ },
+ ],
+ gridLayout: [],
+ },
+ ],
+ },
+ },
+ ]),
+ );
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.message).toMatch(/write mode.*not enabled/i);
+ });
+
+ it("succeeds without widgetId (legacy form-widget path)", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockDb.select.mockReturnValue(drizzleSelectChain([fakeConnection]));
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: { ok: 1 } });
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ // No widgetId or dashboardId — form widget legacy path
+ }),
+ );
+ expect(res.status).toBe(200);
+ });
+
+ it("returns 200 when widget has allowWrites=true", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([fakeConnection]))
+ .mockReturnValueOnce(
+ drizzleSelectChain([
+ {
+ id: "d1",
+ tenantId: "tenant-a",
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ allowWrites: true,
+ },
+ ],
+ gridLayout: [],
+ },
+ ],
+ },
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: { nodesCreated: 1 } });
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(200);
+ });
+
+ it("returns 404 when dashboard not found", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([fakeConnection]))
+ .mockReturnValueOnce(drizzleSelectChain([])); // dashboard not found
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("applies per-card database override when connection allows it", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockDb.select
+ .mockReturnValueOnce(
+ drizzleSelectChain([{ ...fakeConnection, allowPerCardDb: true }]),
+ )
+ .mockReturnValueOnce(
+ drizzleSelectChain([
+ {
+ id: "d1",
+ tenantId: "tenant-a",
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ allowWrites: true,
+ database: "analytics",
+ },
+ ],
+ gridLayout: [],
+ },
+ ],
+ },
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: { nodesCreated: 1 } });
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(200);
+ expect(mockExecuteQuery).toHaveBeenCalledWith(
+ "neo4j",
+ expect.objectContaining({ database: "analytics" }),
+ expect.any(Object),
+ { accessMode: "WRITE" },
+ );
+ });
+
+ it("ignores per-card database override when connection disallows it", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockDb.select
+ .mockReturnValueOnce(
+ drizzleSelectChain([{ ...fakeConnection, allowPerCardDb: false }]),
+ )
+ .mockReturnValueOnce(
+ drizzleSelectChain([
+ {
+ id: "d1",
+ tenantId: "tenant-a",
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ allowWrites: true,
+ database: "analytics",
+ },
+ ],
+ gridLayout: [],
+ },
+ ],
+ },
+ },
+ ]),
+ );
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ database: "primary",
+ });
+ mockExecuteQuery.mockResolvedValue({ data: { nodesCreated: 1 } });
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(200);
+ // Should use original credentials with connection-level database preserved
+ expect(mockExecuteQuery).toHaveBeenCalledWith(
+ "neo4j",
+ {
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ database: "primary",
+ },
+ expect.any(Object),
+ { accessMode: "WRITE" },
+ );
+ });
+
+ it("returns 403 when widget allowWrites is missing (legacy widget)", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([fakeConnection]))
+ .mockReturnValueOnce(
+ drizzleSelectChain([
+ {
+ id: "d1",
+ tenantId: "tenant-a",
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ // allowWrites intentionally omitted — legacy widget
+ },
+ ],
+ gridLayout: [],
+ },
+ ],
+ },
+ },
+ ]),
+ );
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 403 when widget connectionId does not match request connectionId", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockDb.select
+ .mockReturnValueOnce(drizzleSelectChain([fakeConnection]))
+ .mockReturnValueOnce(
+ drizzleSelectChain([
+ {
+ id: "d1",
+ tenantId: "tenant-a",
+ layoutJson: {
+ version: 2,
+ pages: [
+ {
+ id: "p1",
+ title: "Page 1",
+ widgets: [
+ {
+ id: "w1",
+ chartType: "table",
+ connectionId: "c-other", // different connection
+ query: "CREATE (n:Test)",
+ allowWrites: true,
+ },
+ ],
+ gridLayout: [],
+ },
+ ],
+ },
+ },
+ ]),
+ );
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.message).toMatch(/does not belong/i);
+ });
+
+ it("does not apply MAX_ROWS truncation on write results", async () => {
+ mockRequireSession.mockResolvedValue(writerSession);
+ mockConnectionAndDashboard();
+ mockDecryptJson.mockReturnValue({
+ uri: "bolt://localhost",
+ username: "neo4j",
+ password: "pass",
+ });
+ // Return a large result (write routes should not truncate)
+ const bigData = Array.from({ length: 15000 }, (_, i) => ({ n: i }));
+ mockExecuteQuery.mockResolvedValue({ data: bigData });
+
+ const res = await POST(
+ makeRequest({
+ connectionId: "c1",
+ query: "CREATE (n:Test)",
+ widgetId: "w1",
+ dashboardId: "d1",
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(15000);
+ expect(body.meta).not.toHaveProperty("truncated");
+ });
+});
diff --git a/app/src/app/api/query/write/route.ts b/app/src/app/api/query/write/route.ts
new file mode 100644
index 000000000..034990553
--- /dev/null
+++ b/app/src/app/api/query/write/route.ts
@@ -0,0 +1,165 @@
+import { z } from "zod";
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { connections, dashboards } from "@/lib/db/schema";
+import type { DashboardLayoutV2, DashboardWidget } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { decryptJson } from "@/lib/crypto/crypto";
+import { executeQuery } from "@/lib/query/query-executor";
+import type { ConnectionCredentials, DbType } from "@/lib/query/query-executor";
+import { runPipeline } from "@/lib/query/pipeline";
+import type { QueryContext } from "@/lib/query/pipeline-types";
+import {
+ validateBody,
+ forbidden,
+ notFound,
+ handleRouteError,
+} from "@/lib/api/api-utils";
+import { apiSuccess } from "@/lib/api/api-response";
+import { logRoute } from "@/lib/api/log-route";
+import { apiLogger } from "@/lib/logger";
+
+const writeQuerySchema = z.object({
+ connectionId: z.string().min(1),
+ query: z.string().min(1),
+ params: z.record(z.unknown()).optional(),
+ /** Widget ID — required so the server can verify allowWrites on the widget. */
+ widgetId: z.string().min(1).optional(),
+ /** Dashboard ID — required alongside widgetId for lookup. */
+ dashboardId: z.string().min(1).optional(),
+});
+
+export async function POST(request: Request) {
+ return logRoute(request, "query-write", () => handleWriteQuery(request));
+}
+
+async function handleWriteQuery(request: Request): Promise {
+ try {
+ const { userId, canWrite, tenantId } = await requireSession();
+
+ if (!canWrite) {
+ return forbidden("Write permission required");
+ }
+
+ const requestId = request.headers.get("x-request-id") ?? undefined;
+ const body = await request.json();
+ const validation = validateBody(writeQuerySchema, body);
+ if (!validation.success) return validation.response;
+
+ const { connectionId, query, params, widgetId, dashboardId } =
+ validation.data;
+
+ // Only connection owners can execute write queries (tenant-scoped)
+ const [connection] = await db
+ .select()
+ .from(connections)
+ .where(
+ and(
+ eq(connections.id, connectionId),
+ eq(connections.userId, userId),
+ eq(connections.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+
+ if (!connection) {
+ return notFound("Connection not found");
+ }
+
+ // Per-widget write enforcement: when widgetId + dashboardId are provided,
+ // verify the widget's allowWrites flag from the dashboard layout.
+ // Form widgets (legacy path) omit these fields — user-level canWrite
+ // is still enforced above.
+ let widgetDatabaseOverride: string | undefined;
+ if (widgetId && dashboardId) {
+ const [dashboard] = await db
+ .select()
+ .from(dashboards)
+ .where(
+ and(
+ eq(dashboards.id, dashboardId),
+ eq(dashboards.tenantId, tenantId),
+ ),
+ )
+ .limit(1);
+
+ if (!dashboard) {
+ return notFound("Dashboard not found");
+ }
+
+ const layout = dashboard.layoutJson as DashboardLayoutV2 | null;
+ const widget = layout?.pages
+ ?.flatMap((p) => p.widgets)
+ .find((w: DashboardWidget) => w.id === widgetId);
+
+ if (!widget) {
+ return notFound("Widget not found in dashboard");
+ }
+
+ if (!widget.allowWrites) {
+ return forbidden("Write mode is not enabled for this widget");
+ }
+
+ // Validate widget is bound to this connection
+ if (widget.connectionId !== connectionId) {
+ return forbidden("Widget does not belong to this connection");
+ }
+
+ // Only apply per-card DB override when the connection allows it
+ if (widget.database && connection.allowPerCardDb) {
+ widgetDatabaseOverride = widget.database;
+ }
+ }
+
+ const credentials = decryptJson(
+ connection.configEncrypted,
+ );
+
+ // Use per-card database override if set and allowed
+ const effectiveCredentials = widgetDatabaseOverride
+ ? { ...credentials, database: widgetDatabaseOverride }
+ : credentials;
+
+ // Write queries always run at P1 — they represent explicit user
+ // intent (form submit, manual write) and must not be shed under
+ // load like auto-refresh reads can be.
+ const metadata: Record = { priority: 1 };
+ if (requestId) metadata.requestId = requestId;
+
+ const ctx: QueryContext = {
+ query,
+ params: params ?? {},
+ connectionId,
+ connectionType: connection.type as DbType,
+ userId,
+ tenantId,
+ accessMode: "write",
+ metadata,
+ };
+
+ const queryStart = performance.now();
+ const result = await runPipeline(ctx, async (pipelineCtx) =>
+ executeQuery(
+ pipelineCtx.connectionType,
+ effectiveCredentials,
+ { query: pipelineCtx.query, params: pipelineCtx.params },
+ { accessMode: "WRITE" },
+ ),
+ );
+ const serverDurationMs = Math.round(performance.now() - queryStart);
+
+ return apiSuccess(result.data, 200, { serverDurationMs });
+ } catch (error) {
+ apiLogger.error(
+ {
+ event: "write_query_failed",
+ err: error instanceof Error ? error.message : String(error),
+ },
+ "write_query_failed",
+ );
+ // safeMessage: write queries echo user SQL in driver errors — never leak.
+ return handleRouteError(error, "Write query execution failed", {
+ safeMessage: true,
+ });
+ }
+}
diff --git a/app/src/app/api/sso-providers/__tests__/route.test.ts b/app/src/app/api/sso-providers/__tests__/route.test.ts
new file mode 100644
index 000000000..0a4e270d1
--- /dev/null
+++ b/app/src/app/api/sso-providers/__tests__/route.test.ts
@@ -0,0 +1,507 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ makeSelectChain,
+ makeInsertChain,
+ makeDeleteChain,
+ makeUpdateChain,
+} from "@/__tests__/helpers/drizzle-mocks";
+import { makeRequest } from "@/__tests__/helpers/request-helpers";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireAdmin =
+ vi.fn<() => Promise<{ userId: string; tenantId: string; role: string }>>();
+const mockEncrypt = vi.fn((s: string) => `encrypted:${s}`);
+
+const mockDb = {
+ select: vi.fn(),
+ insert: vi.fn(),
+ delete: vi.fn(),
+ update: vi.fn(),
+};
+const mockInvalidateCache = vi.fn();
+
+class UnauthorizedError extends Error {
+ constructor() {
+ super("Unauthorized");
+ }
+}
+class ForbiddenError extends Error {
+ constructor() {
+ super("Forbidden");
+ }
+}
+
+vi.mock("@/lib/auth/session", () => ({ requireAdmin: mockRequireAdmin }));
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("@/lib/crypto/crypto", () => ({ encrypt: mockEncrypt }));
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/sso/provider-cache", () => ({
+ invalidateProviderCache: mockInvalidateCache,
+}));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const ADMIN_SESSION = {
+ userId: "admin-1",
+ tenantId: "default",
+ role: "admin",
+ canWrite: true,
+};
+
+const validProvider = {
+ name: "Company SSO",
+ issuer: "https://idp.example.com",
+ clientId: "client-123",
+ clientSecret: "secret-456",
+ scopes: "openid profile email",
+};
+
+// ---------------------------------------------------------------------------
+// Tests — GET /api/sso-providers
+// ---------------------------------------------------------------------------
+
+describe("GET /api/sso-providers", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({
+ requireAdmin: mockRequireAdmin,
+ }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("@/lib/crypto/crypto", () => ({ encrypt: mockEncrypt }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ vi.doMock("@/lib/auth/sso/provider-cache", () => ({
+ invalidateProviderCache: mockInvalidateCache,
+ }));
+ vi.stubEnv("NEOBOARD_EDITION", "enterprise");
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 403 when NEOBOARD_EDITION is not enterprise", async () => {
+ vi.stubEnv("NEOBOARD_EDITION", "");
+ // Re-import to pick up the env change
+ vi.resetModules();
+ vi.doMock("@/lib/auth/session", () => ({
+ requireAdmin: mockRequireAdmin,
+ }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("@/lib/crypto/crypto", () => ({ encrypt: mockEncrypt }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ vi.doMock("@/lib/auth/sso/provider-cache", () => ({
+ invalidateProviderCache: mockInvalidateCache,
+ }));
+ const mod = await import("../route");
+ const res = await mod.GET();
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.message).toMatch(/enterprise/i);
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireAdmin.mockRejectedValue(new UnauthorizedError());
+ const res = await GET(makeRequest(null));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 when non-admin", async () => {
+ mockRequireAdmin.mockRejectedValue(new ForbiddenError());
+ const res = await GET(makeRequest(null));
+ expect(res.status).toBe(403);
+ });
+
+ it("returns empty array when no providers configured", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await GET(makeRequest(null));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual([]);
+ });
+
+ it("returns providers without client secrets", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ const rows = [
+ {
+ id: "sso-1",
+ name: "Company SSO",
+ protocol: "oidc",
+ issuer: "https://idp.example.com",
+ clientId: "client-123",
+ scopes: "openid profile email",
+ claimMappings: null,
+ autoProvision: true,
+ defaultRole: "creator",
+ enforceSso: false,
+ enabled: true,
+ createdAt: new Date("2026-01-01"),
+ updatedAt: new Date("2026-01-01"),
+ },
+ ];
+ mockDb.select.mockReturnValue(makeSelectChain(rows));
+ const res = await GET(makeRequest(null));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(1);
+ expect(body.data[0]).not.toHaveProperty("clientSecretEncrypted");
+ expect(body.data[0].name).toBe("Company SSO");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests — POST /api/sso-providers
+// ---------------------------------------------------------------------------
+
+describe("POST /api/sso-providers", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({
+ requireAdmin: mockRequireAdmin,
+ }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("@/lib/crypto/crypto", () => ({ encrypt: mockEncrypt }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+ const mod = await import("../route");
+ POST = mod.POST;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireAdmin.mockRejectedValue(new UnauthorizedError());
+ const res = await POST(makeRequest(validProvider));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 400 when name is missing", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ const res = await POST(makeRequest({ ...validProvider, name: undefined }));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when issuer is not a valid URL", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ const res = await POST(
+ makeRequest({ ...validProvider, issuer: "not-a-url" }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when clientId is missing", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ const res = await POST(
+ makeRequest({ ...validProvider, clientId: undefined }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when clientSecret is missing", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ const res = await POST(
+ makeRequest({ ...validProvider, clientSecret: undefined }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 409 when duplicate issuer for tenant", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ // Count check passes (under limit)
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ // Insert fails with unique constraint violation
+ mockDb.insert.mockReturnValue({
+ values: () => ({
+ returning: () =>
+ Promise.reject(
+ new Error(
+ 'duplicate key value violates unique constraint "sso_provider_tenant_issuer_unique"',
+ ),
+ ),
+ }),
+ });
+ const res = await POST(makeRequest(validProvider));
+ expect(res.status).toBe(409);
+ });
+
+ it("returns 409 when max providers (5) reached", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ // Count check: 5 existing providers (at limit)
+ mockDb.select.mockReturnValue(
+ makeSelectChain([
+ { id: "1" },
+ { id: "2" },
+ { id: "3" },
+ { id: "4" },
+ { id: "5" },
+ ]),
+ );
+ const res = await POST(makeRequest(validProvider));
+ expect(res.status).toBe(409);
+ });
+
+ it("encrypts client secret before storing", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ // Count check: under limit
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+
+ let capturedValues: Record | null = null;
+ const insertChain = {
+ values: (vals: Record) => {
+ capturedValues = vals;
+ return insertChain;
+ },
+ returning: () =>
+ Promise.resolve([
+ {
+ id: "new-sso",
+ name: "Company SSO",
+ protocol: "oidc",
+ issuer: "https://idp.example.com",
+ clientId: "client-123",
+ enabled: true,
+ createdAt: new Date(),
+ },
+ ]),
+ };
+ mockDb.insert.mockReturnValue(insertChain);
+
+ await POST(makeRequest(validProvider));
+
+ expect(capturedValues).not.toBeNull();
+ expect(mockEncrypt).toHaveBeenCalledWith("secret-456");
+ expect(capturedValues!.clientSecretEncrypted).toBe("encrypted:secret-456");
+ // Raw secret must NOT be stored
+ expect(capturedValues!).not.toHaveProperty("clientSecret");
+ });
+
+ it("returns 201 with created provider on valid request", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([]))
+ .mockReturnValueOnce(makeSelectChain([]));
+
+ const insertedRow = {
+ id: "new-sso",
+ name: "Company SSO",
+ protocol: "oidc",
+ issuer: "https://idp.example.com",
+ clientId: "client-123",
+ scopes: "openid profile email",
+ claimMappings: null,
+ autoProvision: true,
+ defaultRole: "creator",
+ enforceSso: false,
+ enabled: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockDb.insert.mockReturnValue(makeInsertChain([insertedRow]));
+ const res = await POST(makeRequest(validProvider));
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data.name).toBe("Company SSO");
+ expect(body.data).not.toHaveProperty("clientSecretEncrypted");
+ });
+
+ it("stores tenantId from session", async () => {
+ mockRequireAdmin.mockResolvedValue({
+ ...ADMIN_SESSION,
+ tenantId: "tenant-x",
+ });
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([]))
+ .mockReturnValueOnce(makeSelectChain([]));
+
+ let capturedValues: Record | null = null;
+ const insertChain = {
+ values: (vals: Record) => {
+ capturedValues = vals;
+ return insertChain;
+ },
+ returning: () =>
+ Promise.resolve([{ id: "sso-1", name: "SSO", createdAt: new Date() }]),
+ };
+ mockDb.insert.mockReturnValue(insertChain);
+
+ await POST(makeRequest(validProvider));
+ expect(capturedValues).not.toBeNull();
+ expect(capturedValues!.tenantId).toBe("tenant-x");
+ });
+
+ it("accepts optional claim mappings", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([]))
+ .mockReturnValueOnce(makeSelectChain([]));
+
+ let capturedValues: Record | null = null;
+ const insertChain = {
+ values: (vals: Record) => {
+ capturedValues = vals;
+ return insertChain;
+ },
+ returning: () =>
+ Promise.resolve([{ id: "sso-1", name: "SSO", createdAt: new Date() }]),
+ };
+ mockDb.insert.mockReturnValue(insertChain);
+
+ const claimMappings = {
+ claimKey: "groups",
+ adminValue: "neoboard-admins",
+ creatorValue: "neoboard-editors",
+ readerValue: "neoboard-viewers",
+ };
+
+ await POST(makeRequest({ ...validProvider, claimMappings }));
+ expect(capturedValues).not.toBeNull();
+ expect(capturedValues!.claimMappings).toEqual(claimMappings);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests — DELETE /api/sso-providers
+// ---------------------------------------------------------------------------
+
+describe("DELETE /api/sso-providers", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let DELETE: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({
+ requireAdmin: mockRequireAdmin,
+ }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("@/lib/crypto/crypto", () => ({ encrypt: mockEncrypt }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ vi.doMock("@/lib/auth/sso/provider-cache", () => ({
+ invalidateProviderCache: mockInvalidateCache,
+ }));
+ vi.stubEnv("NEOBOARD_EDITION", "enterprise");
+ const mod = await import("../route");
+ DELETE = mod.DELETE;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireAdmin.mockRejectedValue(new UnauthorizedError());
+ const res = await DELETE(
+ makeRequest(null, "http://localhost/api/sso-providers?id=sso-1"),
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 400 when id is missing", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ const res = await DELETE(
+ makeRequest(null, "http://localhost/api/sso-providers"),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when provider not found", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ mockDb.delete.mockReturnValue(makeDeleteChain([]));
+ const res = await DELETE(
+ makeRequest(null, "http://localhost/api/sso-providers?id=nonexistent"),
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 200 when provider deleted successfully", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ mockDb.delete.mockReturnValue(
+ makeDeleteChain([{ id: "sso-1", name: "Company SSO" }]),
+ );
+ const res = await DELETE(
+ makeRequest(null, "http://localhost/api/sso-providers?id=sso-1"),
+ );
+ expect(res.status).toBe(200);
+ expect(mockInvalidateCache).toHaveBeenCalledWith("default");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests — PATCH /api/sso-providers
+// ---------------------------------------------------------------------------
+
+describe("PATCH /api/sso-providers", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let PATCH: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({
+ requireAdmin: mockRequireAdmin,
+ }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("@/lib/crypto/crypto", () => ({ encrypt: mockEncrypt }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ vi.doMock("@/lib/auth/sso/provider-cache", () => ({
+ invalidateProviderCache: mockInvalidateCache,
+ }));
+ const mod = await import("../route");
+ PATCH = mod.PATCH;
+ });
+
+ it("returns 403 for non-admin", async () => {
+ mockRequireAdmin.mockRejectedValue(new ForbiddenError());
+ const res = await PATCH(makeRequest({ id: "sso-1", name: "Updated" }));
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 400 when id is missing", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ const res = await PATCH(makeRequest({ name: "Updated" }));
+ expect(res.status).toBe(400);
+ });
+
+ it("updates provider and invalidates cache", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ mockDb.update.mockReturnValue(
+ makeUpdateChain([
+ {
+ id: "sso-1",
+ name: "Updated SSO",
+ issuer: "https://idp.example.com",
+ enabled: true,
+ },
+ ]),
+ );
+ const res = await PATCH(
+ makeRequest({ id: "sso-1", name: "Updated SSO", enforceSso: true }),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.name).toBe("Updated SSO");
+ expect(mockInvalidateCache).toHaveBeenCalledWith("default");
+ });
+
+ it("encrypts clientSecret when provided", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ mockDb.update.mockReturnValue(
+ makeUpdateChain([{ id: "sso-1", name: "SSO" }]),
+ );
+ await PATCH(makeRequest({ id: "sso-1", clientSecret: "new-secret-789" }));
+ expect(mockEncrypt).toHaveBeenCalledWith("new-secret-789");
+ });
+
+ it("returns 404 when provider not found", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN_SESSION);
+ mockDb.update.mockReturnValue(makeUpdateChain([]));
+ const res = await PATCH(makeRequest({ id: "nonexistent", name: "Nope" }));
+ expect(res.status).toBe(404);
+ });
+});
diff --git a/app/src/app/api/sso-providers/route.ts b/app/src/app/api/sso-providers/route.ts
new file mode 100644
index 000000000..b223926e8
--- /dev/null
+++ b/app/src/app/api/sso-providers/route.ts
@@ -0,0 +1,262 @@
+import { z } from "zod";
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { ssoProviders } from "@/lib/db/schema";
+import { requireAdmin } from "@/lib/auth/session";
+import { encrypt } from "@/lib/crypto/crypto";
+import { validateBody, handleRouteError, forbidden } from "@/lib/api/api-utils";
+import { apiSuccess, apiError } from "@/lib/api/api-response";
+import { invalidateProviderCache } from "@/lib/auth/sso/provider-cache";
+
+const MAX_PROVIDERS_PER_TENANT = 5;
+
+/** SSO management requires NEOBOARD_EDITION=enterprise. */
+function requireEnterprise() {
+ if (process.env.NEOBOARD_EDITION !== "enterprise") {
+ return forbidden("SSO requires NEOBOARD_EDITION=enterprise");
+ }
+ return null;
+}
+
+const claimMappingSchema = z.object({
+ claimKey: z.string().min(1),
+ adminValue: z.string().optional(),
+ creatorValue: z.string().optional(),
+ readerValue: z.string().optional(),
+});
+
+const createProviderSchema = z.object({
+ name: z.string().min(1),
+ issuer: z.string().url(),
+ clientId: z.string().min(1),
+ clientSecret: z.string().min(1),
+ scopes: z.string().optional().default("openid profile email"),
+ claimMappings: claimMappingSchema.optional(),
+ autoProvision: z.boolean().optional().default(true),
+ defaultRole: z
+ .enum(["admin", "creator", "reader"])
+ .optional()
+ .default("creator"),
+ enforceSso: z.boolean().optional().default(false),
+});
+
+const updateProviderSchema = z.object({
+ id: z.string().min(1),
+ name: z.string().min(1).optional(),
+ clientId: z.string().min(1).optional(),
+ clientSecret: z.string().min(1).optional(),
+ scopes: z.string().optional(),
+ claimMappings: claimMappingSchema.nullable().optional(),
+ autoProvision: z.boolean().optional(),
+ defaultRole: z.enum(["admin", "creator", "reader"]).optional(),
+ enforceSso: z.boolean().optional(),
+ enabled: z.boolean().optional(),
+});
+
+export async function GET() {
+ const gate = requireEnterprise();
+ if (gate) return gate;
+ try {
+ const { tenantId } = await requireAdmin();
+
+ const rows = await db
+ .select({
+ id: ssoProviders.id,
+ name: ssoProviders.name,
+ protocol: ssoProviders.protocol,
+ issuer: ssoProviders.issuer,
+ clientId: ssoProviders.clientId,
+ scopes: ssoProviders.scopes,
+ claimMappings: ssoProviders.claimMappings,
+ autoProvision: ssoProviders.autoProvision,
+ defaultRole: ssoProviders.defaultRole,
+ enforceSso: ssoProviders.enforceSso,
+ enabled: ssoProviders.enabled,
+ createdAt: ssoProviders.createdAt,
+ updatedAt: ssoProviders.updatedAt,
+ })
+ .from(ssoProviders)
+ .where(eq(ssoProviders.tenantId, tenantId));
+
+ return apiSuccess(rows);
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
+
+export async function POST(request: Request) {
+ const gate = requireEnterprise();
+ if (gate) return gate;
+ try {
+ const { tenantId } = await requireAdmin();
+
+ const body = await request.json();
+ const result = validateBody(createProviderSchema, body);
+ if (!result.success) return result.response;
+
+ const {
+ name,
+ issuer,
+ clientId,
+ clientSecret,
+ scopes,
+ claimMappings,
+ autoProvision,
+ defaultRole,
+ enforceSso,
+ } = result.data;
+
+ // Check max providers limit before insert to give a clear error message.
+ // The unique constraint on (tenantId, issuer) handles duplicate detection atomically.
+ const providerCount = await db
+ .select({ id: ssoProviders.id })
+ .from(ssoProviders)
+ .where(eq(ssoProviders.tenantId, tenantId));
+
+ if (providerCount.length >= MAX_PROVIDERS_PER_TENANT) {
+ return apiError(
+ "CONFLICT",
+ "Maximum of " +
+ String(MAX_PROVIDERS_PER_TENANT) +
+ " SSO providers per tenant",
+ );
+ }
+
+ try {
+ const [provider] = await db
+ .insert(ssoProviders)
+ .values({
+ tenantId,
+ name,
+ issuer,
+ clientId,
+ clientSecretEncrypted: encrypt(clientSecret),
+ scopes,
+ claimMappings: claimMappings ?? null,
+ autoProvision,
+ defaultRole,
+ enforceSso,
+ })
+ .returning({
+ id: ssoProviders.id,
+ name: ssoProviders.name,
+ protocol: ssoProviders.protocol,
+ issuer: ssoProviders.issuer,
+ clientId: ssoProviders.clientId,
+ scopes: ssoProviders.scopes,
+ claimMappings: ssoProviders.claimMappings,
+ autoProvision: ssoProviders.autoProvision,
+ defaultRole: ssoProviders.defaultRole,
+ enforceSso: ssoProviders.enforceSso,
+ enabled: ssoProviders.enabled,
+ createdAt: ssoProviders.createdAt,
+ updatedAt: ssoProviders.updatedAt,
+ });
+
+ invalidateProviderCache(tenantId);
+ return apiSuccess(provider, 201);
+ } catch (err: unknown) {
+ // Unique constraint violation on (tenantId, issuer) — duplicate provider
+ if (
+ err instanceof Error &&
+ err.message.includes("sso_provider_tenant_issuer_unique")
+ ) {
+ return apiError(
+ "CONFLICT",
+ "An SSO provider with this issuer already exists",
+ );
+ }
+ throw err;
+ }
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
+
+export async function DELETE(request: Request) {
+ const gate = requireEnterprise();
+ if (gate) return gate;
+ try {
+ const { tenantId } = await requireAdmin();
+
+ const url = new URL(request.url);
+ const id = url.searchParams.get("id");
+
+ if (!id) {
+ return apiError("BAD_REQUEST", "Missing required query parameter: id");
+ }
+
+ const deleted = await db
+ .delete(ssoProviders)
+ .where(and(eq(ssoProviders.id, id), eq(ssoProviders.tenantId, tenantId)))
+ .returning({ id: ssoProviders.id, name: ssoProviders.name });
+
+ if (deleted.length === 0) {
+ return apiError("NOT_FOUND", "SSO provider not found");
+ }
+
+ invalidateProviderCache(tenantId);
+ return apiSuccess(deleted[0]);
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
+
+export async function PATCH(request: Request) {
+ const gate = requireEnterprise();
+ if (gate) return gate;
+ try {
+ const { tenantId } = await requireAdmin();
+
+ const body = await request.json();
+ const result = validateBody(updateProviderSchema, body);
+ if (!result.success) return result.response;
+
+ const { id, clientSecret, ...fields } = result.data;
+
+ // Build the update set — only include fields that were provided
+ const updateSet: Record = { updatedAt: new Date() };
+ if (fields.name !== undefined) updateSet.name = fields.name;
+ if (fields.clientId !== undefined) updateSet.clientId = fields.clientId;
+ if (fields.scopes !== undefined) updateSet.scopes = fields.scopes;
+ if (fields.claimMappings !== undefined)
+ updateSet.claimMappings = fields.claimMappings;
+ if (fields.autoProvision !== undefined)
+ updateSet.autoProvision = fields.autoProvision;
+ if (fields.defaultRole !== undefined)
+ updateSet.defaultRole = fields.defaultRole;
+ if (fields.enforceSso !== undefined)
+ updateSet.enforceSso = fields.enforceSso;
+ if (fields.enabled !== undefined) updateSet.enabled = fields.enabled;
+ if (clientSecret !== undefined)
+ updateSet.clientSecretEncrypted = encrypt(clientSecret);
+
+ const [updated] = await db
+ .update(ssoProviders)
+ .set(updateSet)
+ .where(and(eq(ssoProviders.id, id), eq(ssoProviders.tenantId, tenantId)))
+ .returning({
+ id: ssoProviders.id,
+ name: ssoProviders.name,
+ protocol: ssoProviders.protocol,
+ issuer: ssoProviders.issuer,
+ clientId: ssoProviders.clientId,
+ scopes: ssoProviders.scopes,
+ claimMappings: ssoProviders.claimMappings,
+ autoProvision: ssoProviders.autoProvision,
+ defaultRole: ssoProviders.defaultRole,
+ enforceSso: ssoProviders.enforceSso,
+ enabled: ssoProviders.enabled,
+ updatedAt: ssoProviders.updatedAt,
+ });
+
+ if (!updated) {
+ return apiError("NOT_FOUND", "SSO provider not found");
+ }
+
+ invalidateProviderCache(tenantId);
+ return apiSuccess(updated);
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
diff --git a/app/src/app/api/users/[id]/__tests__/route.test.ts b/app/src/app/api/users/[id]/__tests__/route.test.ts
new file mode 100644
index 000000000..6cb63e24a
--- /dev/null
+++ b/app/src/app/api/users/[id]/__tests__/route.test.ts
@@ -0,0 +1,373 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ makeSelectChain,
+ makeUpdateChain,
+} from "@/__tests__/helpers/drizzle-mocks";
+import { makeRequest, makeParams } from "@/__tests__/helpers/request-helpers";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireAdmin =
+ vi.fn<
+ () => Promise<{ userId: string; canWrite: boolean; tenantId: string }>
+ >();
+
+const mockDb = {
+ select: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+};
+
+/** Track captured update fields for assertions */
+let lastUpdateFields: Record = {};
+
+class UnauthorizedError extends Error {
+ constructor() {
+ super("Unauthorized");
+ }
+}
+class ForbiddenError extends Error {
+ constructor() {
+ super("Forbidden");
+ }
+}
+
+vi.mock("@/lib/auth/session", () => ({ requireAdmin: mockRequireAdmin }));
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("next/server", () => nextResponseMockFactory());
+
+const ADMIN = { userId: "admin-1", canWrite: true, tenantId: "default" };
+const READONLY_ADMIN = {
+ userId: "admin-1",
+ canWrite: false,
+ tenantId: "default",
+};
+
+// ---------------------------------------------------------------------------
+// GET /api/users/[id]
+// ---------------------------------------------------------------------------
+
+describe("GET /api/users/[id]", () => {
+ let GET: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({ requireAdmin: mockRequireAdmin }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireAdmin.mockRejectedValue(new UnauthorizedError());
+ const res = await GET(makeRequest({}), makeParams("u1"));
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.error.code).toBe("UNAUTHORIZED");
+ });
+
+ it("returns single user in envelope", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ const user = {
+ id: "u1",
+ name: "Alice",
+ email: "alice@example.com",
+ role: "creator",
+ canWrite: true,
+ createdAt: new Date(),
+ };
+ mockDb.select.mockReturnValue(makeSelectChain([user]));
+
+ const res = await GET(makeRequest({}), makeParams("u1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.id).toBe("u1");
+ expect(body.data.name).toBe("Alice");
+ expect(body.error).toBeNull();
+ });
+
+ it("returns 404 when user not found", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+
+ const res = await GET(makeRequest({}), makeParams("nonexistent"));
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.code).toBe("NOT_FOUND");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// PATCH /api/users/[id]
+// ---------------------------------------------------------------------------
+
+describe("PATCH /api/users/[id]", () => {
+ let PATCH: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({ requireAdmin: mockRequireAdmin }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ const mod = await import("../route");
+ PATCH = mod.PATCH;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireAdmin.mockRejectedValue(new UnauthorizedError());
+ const res = await PATCH(makeRequest({ canWrite: false }), makeParams("u1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("updates canWrite field and returns envelope", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ const updated = {
+ id: "u1",
+ name: "Bob",
+ email: "bob@example.com",
+ role: "creator",
+ canWrite: false,
+ createdAt: new Date(),
+ };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ const res = await PATCH(makeRequest({ canWrite: false }), makeParams("u1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.canWrite).toBe(false);
+ expect(body.error).toBeNull();
+ });
+
+ it("updates both role and canWrite", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ const updated = {
+ id: "u2",
+ name: "Eve",
+ email: "eve@example.com",
+ role: "creator",
+ canWrite: false,
+ createdAt: new Date(),
+ };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ const res = await PATCH(
+ makeRequest({ role: "creator", canWrite: false }),
+ makeParams("u2"),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.role).toBe("creator");
+ expect(body.data.canWrite).toBe(false);
+ });
+
+ it("returns 400 when body is empty", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ const res = await PATCH(makeRequest({}), makeParams("u3"));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when self-editing", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ const res = await PATCH(
+ makeRequest({ canWrite: false }),
+ makeParams("admin-1"),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when user not found", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ mockDb.update.mockReturnValue(makeUpdateChain([]));
+ const res = await PATCH(
+ makeRequest({ canWrite: false }),
+ makeParams("nonexistent"),
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 403 when admin has canWrite=false", async () => {
+ mockRequireAdmin.mockResolvedValue(READONLY_ADMIN);
+ const res = await PATCH(makeRequest({ role: "reader" }), makeParams("u1"));
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.message).toBe("Forbidden");
+ });
+
+ it("disables a user by setting disabled=true", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ const updated = {
+ id: "u1",
+ name: "Bob",
+ email: "bob@example.com",
+ role: "creator",
+ canWrite: true,
+ disabledAt: new Date(),
+ lastLoginAt: null,
+ createdAt: new Date(),
+ };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ const res = await PATCH(makeRequest({ disabled: true }), makeParams("u1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.disabledAt).toBeTruthy();
+ });
+
+ it("re-enables a user by setting disabled=false", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ const updated = {
+ id: "u1",
+ name: "Bob",
+ email: "bob@example.com",
+ role: "creator",
+ canWrite: true,
+ disabledAt: null,
+ lastLoginAt: null,
+ createdAt: new Date(),
+ };
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+
+ const res = await PATCH(makeRequest({ disabled: false }), makeParams("u1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.disabledAt).toBeNull();
+ });
+
+ it("sets passwordChangedAt on demotion (admin→creator)", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ lastUpdateFields = {};
+ const mockSet = vi.fn().mockImplementation((fields) => {
+ lastUpdateFields = fields;
+ return {
+ where: () => ({
+ returning: () =>
+ Promise.resolve([
+ {
+ id: "u2",
+ name: "Eve",
+ email: "eve@example.com",
+ role: "creator",
+ canWrite: true,
+ disabledAt: null,
+ lastLoginAt: null,
+ createdAt: new Date(),
+ },
+ ]),
+ }),
+ };
+ });
+ mockDb.update.mockReturnValue({ set: mockSet });
+ // Must also mock select to return current role as admin
+ mockDb.select.mockReturnValue(makeSelectChain([{ role: "admin" }]));
+
+ const res = await PATCH(makeRequest({ role: "creator" }), makeParams("u2"));
+ expect(res.status).toBe(200);
+ expect(lastUpdateFields.passwordChangedAt).toBeInstanceOf(Date);
+ });
+
+ it("does NOT set passwordChangedAt on promotion (reader→creator)", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ lastUpdateFields = {};
+ const mockSet = vi.fn().mockImplementation((fields) => {
+ lastUpdateFields = fields;
+ return {
+ where: () => ({
+ returning: () =>
+ Promise.resolve([
+ {
+ id: "u2",
+ name: "Eve",
+ email: "eve@example.com",
+ role: "creator",
+ canWrite: true,
+ disabledAt: null,
+ lastLoginAt: null,
+ createdAt: new Date(),
+ },
+ ]),
+ }),
+ };
+ });
+ mockDb.update.mockReturnValue({ set: mockSet });
+ mockDb.select.mockReturnValue(makeSelectChain([{ role: "reader" }]));
+
+ const res = await PATCH(makeRequest({ role: "creator" }), makeParams("u2"));
+ expect(res.status).toBe(200);
+ expect(lastUpdateFields.passwordChangedAt).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// DELETE /api/users/[id]
+// ---------------------------------------------------------------------------
+
+describe("DELETE /api/users/[id]", () => {
+ let DELETE: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({ requireAdmin: mockRequireAdmin }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+ const mod = await import("../route");
+ DELETE = mod.DELETE;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireAdmin.mockRejectedValue(new UnauthorizedError());
+ const res = await DELETE(makeRequest({}), makeParams("u1"));
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 when admin has canWrite=false", async () => {
+ mockRequireAdmin.mockResolvedValue(READONLY_ADMIN);
+ const res = await DELETE(makeRequest({}), makeParams("u1"));
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.message).toBe("Forbidden");
+ });
+
+ it("returns 400 when self-deleting", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ const res = await DELETE(makeRequest({}), makeParams("admin-1"));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when user not found", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ mockDb.delete.mockReturnValue({
+ where: () => ({ returning: () => Promise.resolve([]) }),
+ });
+ const res = await DELETE(makeRequest({}), makeParams("nonexistent"));
+ expect(res.status).toBe(404);
+ });
+
+ it("deletes user and returns envelope", async () => {
+ mockRequireAdmin.mockResolvedValue(ADMIN);
+ mockDb.delete.mockReturnValue({
+ where: () => ({ returning: () => Promise.resolve([{ id: "u1" }]) }),
+ });
+ const res = await DELETE(makeRequest({}), makeParams("u1"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.deleted).toBe(true);
+ expect(body.error).toBeNull();
+ });
+});
diff --git a/app/src/app/api/users/[id]/reset-password/__tests__/route.test.ts b/app/src/app/api/users/[id]/reset-password/__tests__/route.test.ts
new file mode 100644
index 000000000..fc79fa924
--- /dev/null
+++ b/app/src/app/api/users/[id]/reset-password/__tests__/route.test.ts
@@ -0,0 +1,186 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeUpdateChain } from "@/__tests__/helpers/drizzle-mocks";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireAdmin =
+ vi.fn<
+ () => Promise<{ userId: string; canWrite: boolean; tenantId: string }>
+ >();
+
+const mockDb = {
+ update: vi.fn(),
+};
+
+class UnauthorizedError extends Error {
+ constructor() {
+ super("Unauthorized");
+ }
+}
+class ForbiddenError extends Error {
+ constructor() {
+ super("Forbidden");
+ }
+}
+
+vi.mock("@/lib/auth/session", () => ({
+ requireAdmin: mockRequireAdmin,
+}));
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeRequest(body: unknown) {
+ return { json: async () => body } as Request;
+}
+
+function makeParams(id: string) {
+ return { params: Promise.resolve({ id }) };
+}
+
+// ---------------------------------------------------------------------------
+// Tests — POST /api/users/[id]/reset-password
+// ---------------------------------------------------------------------------
+
+describe("POST /api/users/[id]/reset-password", () => {
+ let POST: (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ POST = mod.POST;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireAdmin.mockRejectedValue(new UnauthorizedError());
+ const res = await POST(
+ makeRequest({ newPassword: "NewPassword1!" }),
+ makeParams("user-2"),
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 when admin cannot write", async () => {
+ mockRequireAdmin.mockResolvedValue({
+ userId: "admin-1",
+ canWrite: false,
+ tenantId: "tenant-a",
+ });
+ const res = await POST(
+ makeRequest({ newPassword: "NewPassword1!" }),
+ makeParams("user-2"),
+ );
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 400 when admin tries to reset own password", async () => {
+ mockRequireAdmin.mockResolvedValue({
+ userId: "admin-1",
+ canWrite: true,
+ tenantId: "tenant-a",
+ });
+ const res = await POST(
+ makeRequest({ newPassword: "NewPassword1!" }),
+ makeParams("admin-1"),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns error when body is invalid", async () => {
+ mockRequireAdmin.mockResolvedValue({
+ userId: "admin-1",
+ canWrite: true,
+ tenantId: "tenant-a",
+ });
+ const res = await POST(makeRequest({}), makeParams("user-2"));
+ // Route catches the error via handleRouteError, returns non-200
+ expect(res.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it("resets password for a user in the same tenant", async () => {
+ mockRequireAdmin.mockResolvedValue({
+ userId: "admin-1",
+ canWrite: true,
+ tenantId: "tenant-a",
+ });
+ mockDb.update.mockReturnValue(makeUpdateChain([{ id: "user-2" }]));
+ const res = await POST(
+ makeRequest({ newPassword: "NewPassword1!" }),
+ makeParams("user-2"),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.reset).toBe(true);
+ });
+
+ it("returns 404 when target user belongs to a different tenant", async () => {
+ mockRequireAdmin.mockResolvedValue({
+ userId: "admin-1",
+ canWrite: true,
+ tenantId: "tenant-a",
+ });
+ // Simulate no rows returned because tenant filter excludes user from tenant-b
+ mockDb.update.mockReturnValue(makeUpdateChain([]));
+ const res = await POST(
+ makeRequest({ newPassword: "NewPassword1!" }),
+ makeParams("user-in-tenant-b"),
+ );
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.message).toBe("User not found");
+ });
+
+ it("returns generated password when generatePassword is true", async () => {
+ mockRequireAdmin.mockResolvedValue({
+ userId: "admin-1",
+ canWrite: true,
+ tenantId: "tenant-a",
+ });
+ mockDb.update.mockReturnValue(makeUpdateChain([{ id: "user-2" }]));
+ const res = await POST(
+ makeRequest({ generatePassword: true }),
+ makeParams("user-2"),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.reset).toBe(true);
+ expect(body.data.generatedPassword).toBeDefined();
+ expect(typeof body.data.generatedPassword).toBe("string");
+ });
+
+ it("sets passwordChangedAt when admin resets password", async () => {
+ mockRequireAdmin.mockResolvedValue({
+ userId: "admin-1",
+ canWrite: true,
+ tenantId: "tenant-a",
+ });
+ let capturedFields: Record = {};
+ const mockSet = vi.fn().mockImplementation((fields) => {
+ capturedFields = fields;
+ return {
+ where: () => ({
+ returning: () => Promise.resolve([{ id: "user-2" }]),
+ }),
+ };
+ });
+ mockDb.update.mockReturnValue({ set: mockSet });
+
+ const res = await POST(
+ makeRequest({ newPassword: "NewPassword1!" }),
+ makeParams("user-2"),
+ );
+ expect(res.status).toBe(200);
+ expect(capturedFields.passwordChangedAt).toBeInstanceOf(Date);
+ });
+});
diff --git a/app/src/app/api/users/[id]/reset-password/route.ts b/app/src/app/api/users/[id]/reset-password/route.ts
new file mode 100644
index 000000000..9102525c3
--- /dev/null
+++ b/app/src/app/api/users/[id]/reset-password/route.ts
@@ -0,0 +1,82 @@
+import { z } from "zod";
+import bcrypt from "bcryptjs";
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { users } from "@/lib/db/schema";
+import { requireAdmin } from "@/lib/auth/session";
+import {
+ forbidden,
+ badRequest,
+ notFound,
+ handleRouteError,
+} from "@/lib/api/api-utils";
+import { apiSuccess } from "@/lib/api/api-response";
+import { newPasswordSchema } from "@/lib/auth/password-schema";
+
+const resetPasswordSchema = z
+ .object({
+ newPassword: newPasswordSchema.optional(),
+ generatePassword: z.boolean().optional().default(false),
+ forcePasswordChange: z.boolean().optional().default(false),
+ })
+ .refine((d) => d.newPassword || d.generatePassword, {
+ message: "Either newPassword or generatePassword must be provided",
+ });
+
+/** Generate a cryptographically random temporary password. */
+function generateTempPassword(length = 16): string {
+ const chars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%";
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
+ return Array.from(bytes, (b) => chars[b % chars.length]).join("");
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, canWrite, tenantId } = await requireAdmin();
+ if (!canWrite) return forbidden();
+ const { id } = await params;
+
+ if (id === userId) {
+ return badRequest(
+ "Use the password change endpoint to change your own password",
+ );
+ }
+
+ const body = await request.json();
+ const parsed = resetPasswordSchema.safeParse(body);
+ if (!parsed.success) {
+ return badRequest(parsed.error.errors[0].message);
+ }
+
+ const password = parsed.data.newPassword ?? generateTempPassword();
+ const passwordHash = await bcrypt.hash(password, 12);
+
+ const updateFields: Record = {
+ passwordHash,
+ passwordChangedAt: new Date(),
+ };
+ if (parsed.data.forcePasswordChange) {
+ updateFields.forcePasswordChange = true;
+ }
+
+ const [updated] = await db
+ .update(users)
+ .set(updateFields)
+ .where(and(eq(users.id, id), eq(users.tenantId, tenantId)))
+ .returning({ id: users.id });
+
+ if (!updated) {
+ return notFound("User not found");
+ }
+
+ return apiSuccess({
+ reset: true,
+ ...(parsed.data.generatePassword ? { generatedPassword: password } : {}),
+ });
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
diff --git a/app/src/app/api/users/[id]/route.ts b/app/src/app/api/users/[id]/route.ts
new file mode 100644
index 000000000..74278d289
--- /dev/null
+++ b/app/src/app/api/users/[id]/route.ts
@@ -0,0 +1,164 @@
+import { z } from "zod";
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { users } from "@/lib/db/schema";
+import { requireAdmin } from "@/lib/auth/session";
+import {
+ validateBody,
+ forbidden,
+ badRequest,
+ notFound,
+ handleRouteError,
+} from "@/lib/api/api-utils";
+import { apiSuccess } from "@/lib/api/api-response";
+
+const updateUserSchema = z
+ .object({
+ role: z.enum(["admin", "creator", "reader"]).optional(),
+ canWrite: z.boolean().optional(),
+ disabled: z.boolean().optional(),
+ })
+ .refine(
+ (d) =>
+ d.role !== undefined ||
+ d.canWrite !== undefined ||
+ d.disabled !== undefined,
+ {
+ message: "At least one field must be provided",
+ },
+ );
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { tenantId } = await requireAdmin();
+ const { id } = await params;
+
+ const [user] = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ canWrite: users.canWrite,
+ disabledAt: users.disabledAt,
+ lastLoginAt: users.lastLoginAt,
+ createdAt: users.createdAt,
+ })
+ .from(users)
+ .where(and(eq(users.id, id), eq(users.tenantId, tenantId)))
+ .limit(1);
+
+ if (!user) {
+ return notFound("User not found");
+ }
+
+ return apiSuccess(user);
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
+
+export async function PATCH(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, canWrite, tenantId } = await requireAdmin();
+ if (!canWrite) return forbidden();
+ const { id } = await params;
+
+ if (id === userId) {
+ return badRequest("You cannot change your own role");
+ }
+
+ const body = await request.json();
+ const result = validateBody(updateUserSchema, body);
+ if (!result.success) return result.response;
+
+ const updateFields: {
+ role?: "admin" | "creator" | "reader";
+ canWrite?: boolean;
+ disabledAt?: Date | null;
+ passwordChangedAt?: Date;
+ } = {};
+ if (result.data.role !== undefined) updateFields.role = result.data.role;
+ if (result.data.canWrite !== undefined)
+ updateFields.canWrite = result.data.canWrite;
+ if (result.data.disabled !== undefined)
+ updateFields.disabledAt = result.data.disabled ? new Date() : null;
+
+ // Invalidate sessions on privilege reduction (demotion)
+ if (result.data.role !== undefined) {
+ const roleRank: Record = {
+ admin: 3,
+ creator: 2,
+ reader: 1,
+ };
+ const [currentUser] = await db
+ .select({ role: users.role })
+ .from(users)
+ .where(and(eq(users.id, id), eq(users.tenantId, tenantId)))
+ .limit(1);
+ if (
+ currentUser &&
+ roleRank[result.data.role] < roleRank[currentUser.role]
+ ) {
+ updateFields.passwordChangedAt = new Date();
+ }
+ }
+
+ const [updated] = await db
+ .update(users)
+ .set(updateFields)
+ .where(and(eq(users.id, id), eq(users.tenantId, tenantId)))
+ .returning({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ canWrite: users.canWrite,
+ disabledAt: users.disabledAt,
+ lastLoginAt: users.lastLoginAt,
+ createdAt: users.createdAt,
+ });
+
+ if (!updated) {
+ return notFound("User not found");
+ }
+
+ return apiSuccess(updated);
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
+
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { userId, canWrite, tenantId } = await requireAdmin();
+ if (!canWrite) return forbidden();
+ const { id } = await params;
+
+ if (id === userId) {
+ return badRequest("You cannot delete your own account");
+ }
+
+ const deleted = await db
+ .delete(users)
+ .where(and(eq(users.id, id), eq(users.tenantId, tenantId)))
+ .returning({ id: users.id });
+
+ if (!deleted.length) {
+ return notFound("User not found");
+ }
+
+ return apiSuccess({ deleted: true });
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
diff --git a/app/src/app/api/users/__tests__/route.test.ts b/app/src/app/api/users/__tests__/route.test.ts
new file mode 100644
index 000000000..eb1e66e77
--- /dev/null
+++ b/app/src/app/api/users/__tests__/route.test.ts
@@ -0,0 +1,183 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeSelectChain, makeInsertChain } from "@/__tests__/helpers/drizzle-mocks";
+import { makeRequest } from "@/__tests__/helpers/request-helpers";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireAdmin = vi.fn<() => Promise<{ userId: string; tenantId: string }>>();
+const mockBcryptHash = vi.fn(async (pw: string) => `hashed:${pw}`);
+
+const mockDb = {
+ select: vi.fn(),
+ insert: vi.fn(),
+};
+
+class UnauthorizedError extends Error {
+ constructor() {
+ super("Unauthorized");
+ }
+}
+class ForbiddenError extends Error {
+ constructor() {
+ super("Forbidden");
+ }
+}
+
+vi.mock("@/lib/auth/session", () => ({ requireAdmin: mockRequireAdmin }));
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("bcryptjs", () => ({ default: { hash: mockBcryptHash } }));
+vi.mock("next/server", () => nextResponseMockFactory());
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("GET /api/users", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({ requireAdmin: mockRequireAdmin }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("bcryptjs", () => ({ default: { hash: mockBcryptHash } }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireAdmin.mockRejectedValue(new UnauthorizedError());
+ const res = await GET(makeRequest({}, "http://localhost/api/users"));
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.error.code).toBe("UNAUTHORIZED");
+ });
+
+ it("returns 403 when caller is not admin", async () => {
+ mockRequireAdmin.mockRejectedValue(new ForbiddenError());
+ const res = await GET(makeRequest({}, "http://localhost/api/users"));
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.code).toBe("FORBIDDEN");
+ });
+
+ it("returns users in envelope with pagination meta", async () => {
+ mockRequireAdmin.mockResolvedValue({ userId: "admin-1", tenantId: "default" });
+ const rows = [
+ { id: "u1", name: "Alice", email: "alice@example.com", role: "creator", canWrite: true, createdAt: new Date() },
+ { id: "u2", name: "Bob", email: "bob@example.com", role: "creator", canWrite: false, createdAt: new Date() },
+ ];
+ // Count query
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 2 }]));
+ // Data query
+ mockDb.select.mockReturnValueOnce(makeSelectChain(rows));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/users"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(2);
+ expect(body.data[0].canWrite).toBe(true);
+ expect(body.data[1].canWrite).toBe(false);
+ expect(body.error).toBeNull();
+ expect(body.meta).toEqual({ total: 2, limit: 25, offset: 0 });
+ });
+
+ it("respects limit and offset query params", async () => {
+ mockRequireAdmin.mockResolvedValue({ userId: "admin-1", tenantId: "default" });
+ mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 10 }]));
+ mockDb.select.mockReturnValueOnce(makeSelectChain([
+ { id: "u3", name: "Charlie", email: "charlie@example.com", role: "reader", canWrite: false, createdAt: new Date() },
+ ]));
+
+ const res = await GET(makeRequest({}, "http://localhost/api/users?limit=1&offset=2"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(1);
+ expect(body.meta).toEqual({ total: 10, limit: 1, offset: 2 });
+ });
+});
+
+describe("POST /api/users", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.doMock("@/lib/auth/session", () => ({ requireAdmin: mockRequireAdmin }));
+ vi.doMock("@/lib/db", () => ({ db: mockDb }));
+ vi.doMock("bcryptjs", () => ({ default: { hash: mockBcryptHash } }));
+ vi.doMock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+ const mod = await import("../route");
+ POST = mod.POST;
+ });
+
+ it("creates user and returns 201 envelope", async () => {
+ mockRequireAdmin.mockResolvedValue({ userId: "admin-1", tenantId: "default" });
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const created = { id: "u1", name: "Test", email: "test@example.com", role: "creator", canWrite: false, createdAt: new Date() };
+ mockDb.insert.mockReturnValue(makeInsertChain([created]));
+
+ const res = await POST(makeRequest({
+ name: "Test",
+ email: "test@example.com",
+ password: "password123",
+ role: "creator",
+ canWrite: false,
+ }));
+
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data.canWrite).toBe(false);
+ expect(body.data.id).toBe("u1");
+ expect(body.error).toBeNull();
+ });
+
+ it("defaults canWrite to true when omitted", async () => {
+ mockRequireAdmin.mockResolvedValue({ userId: "admin-1", tenantId: "default" });
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const created = { id: "u2", name: "Alice", email: "alice@example.com", role: "creator", canWrite: true, createdAt: new Date() };
+ mockDb.insert.mockReturnValue(makeInsertChain([created]));
+
+ const res = await POST(makeRequest({
+ name: "Alice",
+ email: "alice@example.com",
+ password: "password123",
+ }));
+
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data.canWrite).toBe(true);
+ });
+
+ it("returns 409 envelope when email already exists", async () => {
+ mockRequireAdmin.mockResolvedValue({ userId: "admin-1", tenantId: "default" });
+ mockDb.select.mockReturnValue(makeSelectChain([{ id: "existing" }]));
+
+ const res = await POST(makeRequest({
+ name: "Dup",
+ email: "dup@example.com",
+ password: "password123",
+ }));
+
+ expect(res.status).toBe(409);
+ const body = await res.json();
+ expect(body.error.code).toBe("CONFLICT");
+ expect(body.error.message).toMatch(/already exists/i);
+ });
+
+ it("returns 400 envelope for invalid body", async () => {
+ mockRequireAdmin.mockResolvedValue({ userId: "admin-1", tenantId: "default" });
+
+ const res = await POST(makeRequest({ name: "" }));
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error.code).toBe("VALIDATION_ERROR");
+ });
+});
diff --git a/app/src/app/api/users/me/__tests__/route.test.ts b/app/src/app/api/users/me/__tests__/route.test.ts
new file mode 100644
index 000000000..3fd4a916e
--- /dev/null
+++ b/app/src/app/api/users/me/__tests__/route.test.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const mockSession = {
+ userId: "u1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+};
+vi.mock("@/lib/auth/session", () => ({
+ requireSession: vi.fn().mockResolvedValue(mockSession),
+}));
+
+const mockUser = {
+ id: "u1",
+ name: "Alice",
+ email: "alice@test.com",
+ role: "creator",
+ canWrite: true,
+ createdAt: new Date("2026-01-01"),
+};
+const mockSelect = vi.fn();
+const mockUpdate = vi.fn().mockReturnValue({
+ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
+});
+
+vi.mock("@/lib/db", () => ({
+ db: { select: mockSelect, update: mockUpdate },
+}));
+
+vi.mock("@/lib/db/schema", () => ({
+ users: {
+ id: "id",
+ name: "name",
+ email: "email",
+ role: "role",
+ canWrite: "canWrite",
+ createdAt: "createdAt",
+ },
+}));
+
+describe("GET /api/users/me", () => {
+ let GET: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockSelect.mockReturnValue({
+ from: () => ({
+ where: () => ({ limit: () => Promise.resolve([mockUser]) }),
+ }),
+ });
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns current user profile", async () => {
+ const req = new Request("http://localhost/api/users/me");
+ const res = await GET(req);
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.name).toBe("Alice");
+ expect(body.data.email).toBe("alice@test.com");
+ expect(body.data.role).toBe("creator");
+ });
+});
+
+describe("PUT /api/users/me", () => {
+ let PUT: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockSelect.mockReturnValue({
+ from: () => ({
+ where: () => ({ limit: () => Promise.resolve([mockUser]) }),
+ }),
+ });
+ const mod = await import("../route");
+ PUT = mod.PUT;
+ });
+
+ it("updates user name", async () => {
+ const req = new Request("http://localhost/api/users/me", {
+ method: "PUT",
+ body: JSON.stringify({ name: "Bob" }),
+ headers: { "Content-Type": "application/json" },
+ });
+ const res = await PUT(req);
+ expect(res.status).toBe(200);
+ expect(mockUpdate).toHaveBeenCalled();
+ });
+
+ it("returns 400 when name is empty", async () => {
+ const req = new Request("http://localhost/api/users/me", {
+ method: "PUT",
+ body: JSON.stringify({ name: "" }),
+ headers: { "Content-Type": "application/json" },
+ });
+ const res = await PUT(req);
+ expect(res.status).toBe(400);
+ });
+});
diff --git a/app/src/app/api/users/me/password/__tests__/route.test.ts b/app/src/app/api/users/me/password/__tests__/route.test.ts
new file mode 100644
index 000000000..3701f65bd
--- /dev/null
+++ b/app/src/app/api/users/me/password/__tests__/route.test.ts
@@ -0,0 +1,148 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const mockSession = {
+ userId: "u1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+};
+vi.mock("@/lib/auth/session", () => ({
+ requireSession: vi.fn().mockResolvedValue(mockSession),
+}));
+
+const mockUser = { id: "u1", passwordHash: "$2a$12$fakehash" };
+const mockSelect = vi.fn();
+const mockUpdate = vi.fn().mockReturnValue({
+ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
+});
+vi.mock("@/lib/db", () => ({
+ db: {
+ select: mockSelect,
+ update: mockUpdate,
+ },
+}));
+
+vi.mock("@/lib/db/schema", () => ({
+ users: { id: "id", passwordHash: "passwordHash" },
+}));
+
+vi.mock("bcryptjs", () => ({
+ default: {
+ compare: vi.fn(),
+ hash: vi.fn().mockResolvedValue("$2a$12$newhash"),
+ },
+}));
+
+import bcrypt from "bcryptjs";
+
+describe("PUT /api/users/me/password", () => {
+ let PUT: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ // Setup default select chain
+ mockSelect.mockReturnValue({
+ from: () => ({
+ where: () => ({ limit: () => Promise.resolve([mockUser]) }),
+ }),
+ });
+ vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
+ const mod = await import("../route");
+ PUT = mod.PUT;
+ });
+
+ it("returns 400 when body is missing fields", async () => {
+ const req = new Request("http://localhost/api/users/me/password", {
+ method: "PUT",
+ body: JSON.stringify({}),
+ headers: { "Content-Type": "application/json" },
+ });
+ const res = await PUT(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when new password is too short", async () => {
+ const req = new Request("http://localhost/api/users/me/password", {
+ method: "PUT",
+ body: JSON.stringify({ currentPassword: "old123", newPassword: "short" }),
+ headers: { "Content-Type": "application/json" },
+ });
+ const res = await PUT(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when new password lacks a letter", async () => {
+ const req = new Request("http://localhost/api/users/me/password", {
+ method: "PUT",
+ body: JSON.stringify({
+ currentPassword: "old123",
+ newPassword: "12345678",
+ }),
+ headers: { "Content-Type": "application/json" },
+ });
+ const res = await PUT(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when new password lacks a number", async () => {
+ const req = new Request("http://localhost/api/users/me/password", {
+ method: "PUT",
+ body: JSON.stringify({
+ currentPassword: "old123",
+ newPassword: "abcdefgh",
+ }),
+ headers: { "Content-Type": "application/json" },
+ });
+ const res = await PUT(req);
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 403 when current password is wrong", async () => {
+ vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
+ const req = new Request("http://localhost/api/users/me/password", {
+ method: "PUT",
+ body: JSON.stringify({
+ currentPassword: "wrong",
+ newPassword: "newPass1",
+ }),
+ headers: { "Content-Type": "application/json" },
+ });
+ const res = await PUT(req);
+ expect(res.status).toBe(403);
+ });
+
+ it("returns 200 and updates password on success", async () => {
+ const req = new Request("http://localhost/api/users/me/password", {
+ method: "PUT",
+ body: JSON.stringify({
+ currentPassword: "old123",
+ newPassword: "newPass1",
+ }),
+ headers: { "Content-Type": "application/json" },
+ });
+ const res = await PUT(req);
+ expect(res.status).toBe(200);
+ expect(mockUpdate).toHaveBeenCalled();
+ });
+
+ it("sets passwordChangedAt when password is changed", async () => {
+ let capturedFields: Record = {};
+ const mockSet = vi.fn().mockImplementation((fields) => {
+ capturedFields = fields;
+ return { where: vi.fn().mockResolvedValue(undefined) };
+ });
+ mockUpdate.mockReturnValue({ set: mockSet });
+
+ const req = new Request("http://localhost/api/users/me/password", {
+ method: "PUT",
+ body: JSON.stringify({
+ currentPassword: "old123",
+ newPassword: "newPass1",
+ }),
+ headers: { "Content-Type": "application/json" },
+ });
+ const res = await PUT(req);
+ expect(res.status).toBe(200);
+ expect(capturedFields.passwordChangedAt).toBeInstanceOf(Date);
+ });
+});
diff --git a/app/src/app/api/users/me/password/route.ts b/app/src/app/api/users/me/password/route.ts
new file mode 100644
index 000000000..9dd397eb2
--- /dev/null
+++ b/app/src/app/api/users/me/password/route.ts
@@ -0,0 +1,71 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import bcrypt from "bcryptjs";
+import { eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { users } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { newPasswordSchema } from "@/lib/auth/password-schema";
+
+const passwordSchema = z.object({
+ currentPassword: z.string().min(1, "Current password is required"),
+ newPassword: newPasswordSchema,
+});
+
+export async function PUT(req: Request) {
+ const session = await requireSession();
+
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const parsed = passwordSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.errors[0].message },
+ { status: 400 },
+ );
+ }
+
+ const { currentPassword, newPassword } = parsed.data;
+
+ // Fetch the user's current password hash
+ const user = await db
+ .select({ id: users.id, passwordHash: users.passwordHash })
+ .from(users)
+ .where(eq(users.id, session.userId))
+ .limit(1)
+ .then((rows) => rows[0]);
+
+ if (!user?.passwordHash) {
+ return NextResponse.json(
+ { error: "User not found or has no password" },
+ { status: 404 },
+ );
+ }
+
+ // Verify current password
+ const isValid = await bcrypt.compare(currentPassword, user.passwordHash);
+ if (!isValid) {
+ return NextResponse.json(
+ { error: "Current password is incorrect" },
+ { status: 403 },
+ );
+ }
+
+ // Hash and update — also clear forcePasswordChange so the user is no longer redirected
+ const newHash = await bcrypt.hash(newPassword, 12);
+ await db
+ .update(users)
+ .set({
+ passwordHash: newHash,
+ forcePasswordChange: false,
+ passwordChangedAt: new Date(),
+ })
+ .where(eq(users.id, session.userId));
+
+ return NextResponse.json({ data: { success: true } });
+}
diff --git a/app/src/app/api/users/me/route.ts b/app/src/app/api/users/me/route.ts
new file mode 100644
index 000000000..5505be090
--- /dev/null
+++ b/app/src/app/api/users/me/route.ts
@@ -0,0 +1,62 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { users } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+
+/** GET /api/users/me — return current user profile */
+export async function GET() {
+ const session = await requireSession();
+
+ const user = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ canWrite: users.canWrite,
+ createdAt: users.createdAt,
+ })
+ .from(users)
+ .where(eq(users.id, session.userId))
+ .limit(1)
+ .then((rows) => rows[0]);
+
+ if (!user) {
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
+ }
+
+ return NextResponse.json({ data: user });
+}
+
+const updateSchema = z.object({
+ name: z.string().min(1, "Name is required"),
+});
+
+/** PUT /api/users/me — update current user's name */
+export async function PUT(req: Request) {
+ const session = await requireSession();
+
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const parsed = updateSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.errors[0].message },
+ { status: 400 },
+ );
+ }
+
+ await db
+ .update(users)
+ .set({ name: parsed.data.name })
+ .where(eq(users.id, session.userId));
+
+ return NextResponse.json({ data: { success: true } });
+}
diff --git a/app/src/app/api/users/route.ts b/app/src/app/api/users/route.ts
new file mode 100644
index 000000000..14f4d06f7
--- /dev/null
+++ b/app/src/app/api/users/route.ts
@@ -0,0 +1,105 @@
+import { z } from "zod";
+import { and, count, eq } from "drizzle-orm";
+import bcrypt from "bcryptjs";
+import { db } from "@/lib/db";
+import { users } from "@/lib/db/schema";
+import { requireAdmin } from "@/lib/auth/session";
+import { validateBody, handleRouteError } from "@/lib/api/api-utils";
+import {
+ apiSuccess,
+ apiList,
+ apiError,
+ parsePagination,
+} from "@/lib/api/api-response";
+import { newPasswordSchema } from "@/lib/auth/password-schema";
+
+const createUserSchema = z.object({
+ name: z.string().min(1),
+ email: z.string().email(),
+ password: newPasswordSchema,
+ role: z.enum(["admin", "creator", "reader"]).optional().default("creator"),
+ canWrite: z.boolean().optional().default(true),
+ forcePasswordChange: z.boolean().optional().default(false),
+});
+
+export async function GET(request: Request) {
+ try {
+ const { tenantId } = await requireAdmin();
+ const { limit, offset } = parsePagination(request);
+
+ const [{ count: total }] = await db
+ .select({ count: count() })
+ .from(users)
+ .where(eq(users.tenantId, tenantId));
+
+ const rows = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ canWrite: users.canWrite,
+ disabledAt: users.disabledAt,
+ lastLoginAt: users.lastLoginAt,
+ createdAt: users.createdAt,
+ })
+ .from(users)
+ .where(eq(users.tenantId, tenantId))
+ .limit(limit)
+ .orderBy(users.createdAt)
+ .offset(offset);
+
+ return apiList(rows, { total: Number(total), limit, offset });
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const { tenantId } = await requireAdmin();
+
+ const body = await request.json();
+ const result = validateBody(createUserSchema, body);
+ if (!result.success) return result.response;
+
+ const { name, email, password, role, canWrite, forcePasswordChange } =
+ result.data;
+
+ const existing = await db
+ .select({ id: users.id })
+ .from(users)
+ .where(and(eq(users.email, email), eq(users.tenantId, tenantId)))
+ .limit(1);
+
+ if (existing.length > 0) {
+ return apiError("CONFLICT", "A user with this email already exists");
+ }
+
+ const passwordHash = await bcrypt.hash(password, 12);
+
+ const [user] = await db
+ .insert(users)
+ .values({
+ name,
+ email,
+ passwordHash,
+ role,
+ canWrite,
+ forcePasswordChange,
+ tenantId,
+ })
+ .returning({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ canWrite: users.canWrite,
+ createdAt: users.createdAt,
+ });
+
+ return apiSuccess(user, 201);
+ } catch (e) {
+ return handleRouteError(e);
+ }
+}
diff --git a/app/src/app/api/widget-templates/[id]/__tests__/route.test.ts b/app/src/app/api/widget-templates/[id]/__tests__/route.test.ts
new file mode 100644
index 000000000..5d92de130
--- /dev/null
+++ b/app/src/app/api/widget-templates/[id]/__tests__/route.test.ts
@@ -0,0 +1,224 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { makeSelectChain, makeUpdateChain, makeDeleteChain } from "@/__tests__/helpers/drizzle-mocks";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireSession = vi.fn<
+ () => Promise<{ userId: string; role: string; canWrite: boolean; tenantId: string }>
+>();
+
+const mockDb = {
+ select: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+};
+
+class UnauthorizedError extends Error {
+ constructor() {
+ super("Unauthorized");
+ }
+}
+class ForbiddenError extends Error {
+ constructor() {
+ super("Forbidden");
+ }
+}
+
+vi.mock("@/lib/auth/session", () => ({
+ requireSession: mockRequireSession,
+ requireUserId: vi.fn(),
+}));
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+// ---------------------------------------------------------------------------
+// Tests — GET /api/widget-templates/[id]
+// ---------------------------------------------------------------------------
+
+describe("GET /api/widget-templates/[id]", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: (req: Request, ctx: { params: Promise<{ id: string }> }) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await GET({} as Request, { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when template not found", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await GET({} as Request, { params: Promise.resolve({ id: "missing" }) });
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.message).toBe("Not found");
+ });
+
+ it("returns template wrapped in envelope when found", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ const template = { id: "t1", name: "My Template", chartType: "bar", connectorType: "neo4j", createdBy: "user-1" };
+ mockDb.select.mockReturnValue(makeSelectChain([template]));
+ const res = await GET({} as Request, { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual(template);
+ expect(body.error).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests — PUT /api/widget-templates/[id]
+// ---------------------------------------------------------------------------
+
+describe("PUT /api/widget-templates/[id]", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let PUT: (req: Request, ctx: { params: Promise<{ id: string }> }) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ PUT = mod.PUT;
+ });
+
+ function makeRequest(body: unknown) {
+ return { json: async () => body } as Request;
+ }
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await PUT(makeRequest({ name: "Updated" }), { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 when user cannot write", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: false, tenantId: "default" });
+ const res = await PUT(makeRequest({ name: "Updated" }), { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.message).toBe("Forbidden");
+ });
+
+ it("returns 404 when template not found", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await PUT(makeRequest({ name: "Updated" }), { params: Promise.resolve({ id: "missing" }) });
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.message).toBe("Not found");
+ });
+
+ it("returns 403 when user is not the creator and not admin", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-2", role: "creator", canWrite: true, tenantId: "default" });
+ const existing = { id: "t1", name: "Old", createdBy: "user-1", tenantId: "default" };
+ mockDb.select.mockReturnValue(makeSelectChain([existing]));
+ const res = await PUT(makeRequest({ name: "Updated" }), { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(403);
+ });
+
+ it("allows admin to update any template", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-2", role: "admin", canWrite: true, tenantId: "default" });
+ const existing = { id: "t1", name: "Old", createdBy: "user-1", tenantId: "default" };
+ const updated = { ...existing, name: "Updated" };
+ mockDb.select.mockReturnValue(makeSelectChain([existing]));
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+ const res = await PUT(makeRequest({ name: "Updated" }), { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual(updated);
+ expect(body.error).toBeNull();
+ });
+
+ it("allows creator to update own template", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ const existing = { id: "t1", name: "Old", createdBy: "user-1", tenantId: "default" };
+ const updated = { ...existing, name: "Updated" };
+ mockDb.select.mockReturnValue(makeSelectChain([existing]));
+ mockDb.update.mockReturnValue(makeUpdateChain([updated]));
+ const res = await PUT(makeRequest({ name: "Updated" }), { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual(updated);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests — DELETE /api/widget-templates/[id]
+// ---------------------------------------------------------------------------
+
+describe("DELETE /api/widget-templates/[id]", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let DELETE: (req: Request, ctx: { params: Promise<{ id: string }> }) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ DELETE = mod.DELETE;
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await DELETE({} as Request, { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 when user cannot write", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: false, tenantId: "default" });
+ const res = await DELETE({} as Request, { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.message).toBe("Forbidden");
+ });
+
+ it("returns 404 when template not found", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ mockDb.select.mockReturnValue(makeSelectChain([]));
+ const res = await DELETE({} as Request, { params: Promise.resolve({ id: "missing" }) });
+ expect(res.status).toBe(404);
+ const body = await res.json();
+ expect(body.error.message).toBe("Not found");
+ });
+
+ it("returns 403 when user is not the creator and not admin", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-2", role: "creator", canWrite: true, tenantId: "default" });
+ const existing = { id: "t1", name: "My Template", createdBy: "user-1", tenantId: "default" };
+ mockDb.select.mockReturnValue(makeSelectChain([existing]));
+ const res = await DELETE({} as Request, { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(403);
+ });
+
+ it("deletes template and returns { deleted: true } in envelope", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-1", role: "creator", canWrite: true, tenantId: "default" });
+ const existing = { id: "t1", name: "My Template", createdBy: "user-1", tenantId: "default" };
+ mockDb.select.mockReturnValue(makeSelectChain([existing]));
+ mockDb.delete.mockReturnValue(makeDeleteChain());
+ const res = await DELETE({} as Request, { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual({ deleted: true });
+ expect(body.error).toBeNull();
+ });
+
+ it("allows admin to delete any template", async () => {
+ mockRequireSession.mockResolvedValue({ userId: "user-2", role: "admin", canWrite: true, tenantId: "default" });
+ const existing = { id: "t1", name: "My Template", createdBy: "user-1", tenantId: "default" };
+ mockDb.select.mockReturnValue(makeSelectChain([existing]));
+ mockDb.delete.mockReturnValue(makeDeleteChain());
+ const res = await DELETE({} as Request, { params: Promise.resolve({ id: "t1" }) });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual({ deleted: true });
+ });
+});
diff --git a/app/src/app/api/widget-templates/[id]/route.ts b/app/src/app/api/widget-templates/[id]/route.ts
new file mode 100644
index 000000000..3012cb343
--- /dev/null
+++ b/app/src/app/api/widget-templates/[id]/route.ts
@@ -0,0 +1,139 @@
+import { z } from "zod";
+import { and, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { widgetTemplates } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { apiSuccess } from "@/lib/api/api-response";
+import {
+ forbidden,
+ notFound,
+ badRequest,
+ handleRouteError,
+} from "@/lib/api/api-utils";
+import { previewImageUrlSchema } from "../shared";
+import { CONNECTOR_TYPES } from "@/lib/connector/connector-types";
+
+const updateTemplateSchema = z.object({
+ name: z.string().min(1).max(255).optional(),
+ description: z.string().max(1000).optional(),
+ tags: z.array(z.string().max(100)).max(20).optional(),
+ chartType: z.string().min(1).optional(),
+ connectorType: z.enum(CONNECTOR_TYPES).optional(),
+ connectionId: z.string().nullable().optional(),
+ query: z.string().optional(),
+ params: z.record(z.unknown()).optional(),
+ settings: z.record(z.unknown()).optional(),
+ previewImageUrl: previewImageUrlSchema,
+});
+
+/** Require a writable session and verify the caller owns the template (or is admin). */
+async function requireOwnedTemplate(id: string) {
+ const session = await requireSession();
+ const { userId, role, canWrite, tenantId } = session;
+
+ if (!canWrite) {
+ return { error: forbidden() } as const;
+ }
+
+ const [existing] = await db
+ .select()
+ .from(widgetTemplates)
+ .where(
+ and(eq(widgetTemplates.id, id), eq(widgetTemplates.tenantId, tenantId)),
+ )
+ .limit(1);
+
+ if (!existing) {
+ return { error: notFound() } as const;
+ }
+
+ if (existing.createdBy !== userId && role !== "admin") {
+ return { error: forbidden() } as const;
+ }
+
+ return { template: existing, session } as const;
+}
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { tenantId } = await requireSession();
+ const { id } = await params;
+
+ const [template] = await db
+ .select()
+ .from(widgetTemplates)
+ .where(
+ and(eq(widgetTemplates.id, id), eq(widgetTemplates.tenantId, tenantId)),
+ )
+ .limit(1);
+
+ if (!template) {
+ return notFound();
+ }
+
+ return apiSuccess(template);
+ } catch (err) {
+ return handleRouteError(err);
+ }
+}
+
+export async function PUT(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { id } = await params;
+ const result = await requireOwnedTemplate(id);
+ if ("error" in result) return result.error;
+ const { tenantId } = result.session;
+
+ const body = await request.json();
+ const parsed = updateTemplateSchema.safeParse(body);
+
+ if (!parsed.success) {
+ return badRequest(parsed.error.errors[0].message);
+ }
+
+ const data = parsed.data;
+ const settings = data.settings
+ ? { ...data.settings, connectionId: undefined }
+ : data.settings;
+
+ const [updated] = await db
+ .update(widgetTemplates)
+ .set({ ...data, settings, updatedAt: new Date() })
+ .where(
+ and(eq(widgetTemplates.id, id), eq(widgetTemplates.tenantId, tenantId)),
+ )
+ .returning();
+
+ return apiSuccess(updated);
+ } catch (err) {
+ return handleRouteError(err);
+ }
+}
+
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { id } = await params;
+ const result = await requireOwnedTemplate(id);
+ if ("error" in result) return result.error;
+ const { tenantId } = result.session;
+
+ await db
+ .delete(widgetTemplates)
+ .where(
+ and(eq(widgetTemplates.id, id), eq(widgetTemplates.tenantId, tenantId)),
+ );
+
+ return apiSuccess({ deleted: true });
+ } catch (err) {
+ return handleRouteError(err);
+ }
+}
diff --git a/app/src/app/api/widget-templates/__tests__/route.test.ts b/app/src/app/api/widget-templates/__tests__/route.test.ts
new file mode 100644
index 000000000..87774679b
--- /dev/null
+++ b/app/src/app/api/widget-templates/__tests__/route.test.ts
@@ -0,0 +1,307 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ makeSelectChain,
+ makeInsertChain,
+} from "@/__tests__/helpers/drizzle-mocks";
+import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRequireSession = vi.fn<
+ () => Promise<{
+ userId: string;
+ role: string;
+ canWrite: boolean;
+ tenantId: string;
+ }>
+>();
+
+const mockDb = {
+ select: vi.fn(),
+ insert: vi.fn(),
+};
+
+class UnauthorizedError extends Error {
+ constructor() {
+ super("Unauthorized");
+ }
+}
+class ForbiddenError extends Error {
+ constructor() {
+ super("Forbidden");
+ }
+}
+
+vi.mock("@/lib/auth/session", () => ({
+ requireSession: mockRequireSession,
+ requireUserId: vi.fn(),
+}));
+vi.mock("@/lib/db", () => ({ db: mockDb }));
+vi.mock("next/server", () => nextResponseMockFactory());
+vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));
+
+// ---------------------------------------------------------------------------
+// Tests — GET /api/widget-templates
+// ---------------------------------------------------------------------------
+
+describe("GET /api/widget-templates", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let GET: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ GET = mod.GET;
+ });
+
+ function makeRequest(params?: Record) {
+ const url = new URL("http://localhost/api/widget-templates");
+ if (params) {
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
+ }
+ return { url: url.toString() } as Request;
+ }
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await GET(makeRequest());
+ expect(res.status).toBe(401);
+ });
+
+ it("returns all tenant templates with pagination meta", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ const template = {
+ id: "t1",
+ name: "My Template",
+ chartType: "bar",
+ connectorType: "neo4j",
+ };
+ // First call -> count query ([{ total: 1 }]), second call -> rows
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([{ total: 1 }]))
+ .mockReturnValueOnce(makeSelectChain([template]));
+
+ const res = await GET(makeRequest());
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toHaveLength(1);
+ expect(body.meta).toMatchObject({ total: 1, limit: 25, offset: 0 });
+ });
+
+ it("supports filtering by chartType", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([{ total: 0 }]))
+ .mockReturnValueOnce(makeSelectChain([]));
+ const res = await GET(makeRequest({ chartType: "bar" }));
+ expect(res.status).toBe(200);
+ });
+
+ it("supports filtering by connectorType", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ mockDb.select
+ .mockReturnValueOnce(makeSelectChain([{ total: 0 }]))
+ .mockReturnValueOnce(makeSelectChain([]));
+ const res = await GET(makeRequest({ connectorType: "neo4j" }));
+ expect(res.status).toBe(200);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tests — POST /api/widget-templates
+// ---------------------------------------------------------------------------
+
+describe("POST /api/widget-templates", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let POST: (req: Request) => Promise;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ const mod = await import("../route");
+ POST = mod.POST;
+ });
+
+ function makeRequest(body: unknown) {
+ return { json: async () => body } as Request;
+ }
+
+ it("returns 401 when unauthenticated", async () => {
+ mockRequireSession.mockRejectedValue(new UnauthorizedError());
+ const res = await POST(
+ makeRequest({ name: "T", chartType: "bar", connectorType: "neo4j" }),
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 403 for reader role", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "reader",
+ canWrite: false,
+ tenantId: "default",
+ });
+ const res = await POST(
+ makeRequest({ name: "T", chartType: "bar", connectorType: "neo4j" }),
+ );
+ expect(res.status).toBe(403);
+ const body = await res.json();
+ expect(body.error.message).toBe("Forbidden");
+ });
+
+ it("returns 400 when name is missing", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ const res = await POST(
+ makeRequest({ chartType: "bar", connectorType: "neo4j" }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when chartType is missing", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ const res = await POST(makeRequest({ name: "T", connectorType: "neo4j" }));
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when connectorType is invalid", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ const res = await POST(
+ makeRequest({ name: "T", chartType: "bar", connectorType: "mysql" }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when name exceeds 255 characters", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ const res = await POST(
+ makeRequest({
+ name: "x".repeat(256),
+ chartType: "bar",
+ connectorType: "neo4j",
+ }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when description exceeds 1000 characters", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ const res = await POST(
+ makeRequest({
+ name: "T",
+ chartType: "bar",
+ connectorType: "neo4j",
+ description: "x".repeat(1001),
+ }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when tags exceed 20 items", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ const tags = Array.from({ length: 21 }, (_, i) => `tag-${i}`);
+ const res = await POST(
+ makeRequest({
+ name: "T",
+ chartType: "bar",
+ connectorType: "neo4j",
+ tags,
+ }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when a tag exceeds 100 characters", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ const res = await POST(
+ makeRequest({
+ name: "T",
+ chartType: "bar",
+ connectorType: "neo4j",
+ tags: ["x".repeat(101)],
+ }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it("creates template and returns 201 with envelope", async () => {
+ mockRequireSession.mockResolvedValue({
+ userId: "user-1",
+ role: "creator",
+ canWrite: true,
+ tenantId: "default",
+ });
+ const created = {
+ id: "t1",
+ name: "My Template",
+ chartType: "bar",
+ connectorType: "neo4j",
+ createdBy: "user-1",
+ };
+ mockDb.insert.mockReturnValue(makeInsertChain([created]));
+
+ const res = await POST(
+ makeRequest({
+ name: "My Template",
+ chartType: "bar",
+ connectorType: "neo4j",
+ }),
+ );
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.data).toEqual(created);
+ expect(body.error).toBeNull();
+ });
+});
diff --git a/app/src/app/api/widget-templates/route.ts b/app/src/app/api/widget-templates/route.ts
new file mode 100644
index 000000000..16d002fa7
--- /dev/null
+++ b/app/src/app/api/widget-templates/route.ts
@@ -0,0 +1,94 @@
+import { z } from "zod";
+import { and, count, eq, asc } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { widgetTemplates } from "@/lib/db/schema";
+import { requireSession } from "@/lib/auth/session";
+import { apiSuccess, apiList, parsePagination } from "@/lib/api/api-response";
+import { forbidden, badRequest, handleRouteError } from "@/lib/api/api-utils";
+import { previewImageUrlSchema } from "./shared";
+import { CONNECTOR_TYPES } from "@/lib/connector/connector-types";
+
+const createTemplateSchema = z.object({
+ name: z.string().min(1).max(255),
+ description: z.string().max(1000).optional(),
+ tags: z.array(z.string().max(100)).max(20).optional(),
+ chartType: z.string().min(1),
+ connectorType: z.enum(CONNECTOR_TYPES),
+ connectionId: z.string().optional(),
+ query: z.string().default(""),
+ params: z.record(z.unknown()).optional(),
+ settings: z.record(z.unknown()).optional(),
+ previewImageUrl: previewImageUrlSchema,
+});
+
+export async function GET(request: Request) {
+ try {
+ const { tenantId } = await requireSession();
+ const url = new URL(request.url);
+ const chartType = url.searchParams.get("chartType");
+ const connectorType = url.searchParams.get("connectorType");
+ const { limit, offset } = parsePagination(request);
+
+ const conditions = [eq(widgetTemplates.tenantId, tenantId)];
+ if (chartType) {
+ conditions.push(eq(widgetTemplates.chartType, chartType));
+ }
+ if (connectorType) {
+ conditions.push(eq(widgetTemplates.connectorType, connectorType));
+ }
+
+ const [{ total }] = await db
+ .select({ total: count() })
+ .from(widgetTemplates)
+ .where(and(...conditions));
+
+ const rows = await db
+ .select()
+ .from(widgetTemplates)
+ .where(and(...conditions))
+ .orderBy(asc(widgetTemplates.createdAt), asc(widgetTemplates.id))
+ .limit(limit)
+ .offset(offset);
+
+ return apiList(rows, { total, limit, offset });
+ } catch (err) {
+ return handleRouteError(err);
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const { userId, canWrite, tenantId } = await requireSession();
+
+ if (!canWrite) {
+ return forbidden();
+ }
+
+ const body = await request.json();
+ const parsed = createTemplateSchema.safeParse(body);
+
+ if (!parsed.success) {
+ return badRequest(parsed.error.errors[0].message);
+ }
+
+ const data = parsed.data;
+ // Strip connectionId from settings (it's now a top-level column)
+ const settings = data.settings
+ ? { ...data.settings, connectionId: undefined }
+ : data.settings;
+
+ const [template] = await db
+ .insert(widgetTemplates)
+ .values({
+ ...data,
+ settings,
+ createdBy: userId,
+ tenantId,
+ })
+ .returning();
+
+ return apiSuccess(template, 201);
+ } catch (err) {
+ return handleRouteError(err);
+ }
+}
diff --git a/app/src/app/api/widget-templates/shared.ts b/app/src/app/api/widget-templates/shared.ts
new file mode 100644
index 000000000..94116ded0
--- /dev/null
+++ b/app/src/app/api/widget-templates/shared.ts
@@ -0,0 +1,14 @@
+import { z } from "zod";
+export { handleRouteError } from "@/lib/api/api-utils";
+
+/** Max size for preview image data URIs (500 KB). */
+const MAX_PREVIEW_SIZE = 500 * 1024;
+
+export const previewImageUrlSchema = z
+ .string()
+ .refine((s) => s.startsWith("data:image/"), "Must be a data:image/ URI")
+ .refine(
+ (s) => s.length <= MAX_PREVIEW_SIZE,
+ `Preview image must be under ${MAX_PREVIEW_SIZE / 1024}KB`,
+ )
+ .optional();
diff --git a/app/src/app/error.tsx b/app/src/app/error.tsx
new file mode 100644
index 000000000..b7e8b14ff
--- /dev/null
+++ b/app/src/app/error.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useEffect } from "react";
+
+export default function Error({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error("[app-error]", error);
+ }, [error]);
+
+ return (
+
+
+
Something went wrong
+
+ An unexpected error occurred. Please try again or return to the
+ dashboard.
+
+ {error.digest && (
+
+ Error ID: {error.digest}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/src/app/global-error.tsx b/app/src/app/global-error.tsx
new file mode 100644
index 000000000..744d7d812
--- /dev/null
+++ b/app/src/app/global-error.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useEffect } from "react";
+
+export default function GlobalError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error("[global-error]", error);
+ }, [error]);
+
+ return (
+
+
+
+
+
+ Application Error
+
+
+ A critical error occurred. Please try refreshing the page.
+
+
+ Refresh
+
+
+
+
+
+ );
+}
diff --git a/app/src/app/globals.css b/app/src/app/globals.css
new file mode 100644
index 000000000..1e2efd819
--- /dev/null
+++ b/app/src/app/globals.css
@@ -0,0 +1,30 @@
+@import "../../../component/design-tokens.css";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+@keyframes widget-highlight-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 hsl(var(--primary) / 0.5);
+ }
+ 50% {
+ box-shadow: 0 0 0 4px hsl(var(--primary) / 0.3);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 hsl(var(--primary) / 0);
+ }
+}
+
+.widget-highlight {
+ animation: widget-highlight-pulse 1.5s ease-out;
+}
diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx
new file mode 100644
index 000000000..94963c16e
--- /dev/null
+++ b/app/src/app/layout.tsx
@@ -0,0 +1,51 @@
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import { Providers } from "@/components/providers";
+import "./globals.css";
+
+const inter = Inter({ subsets: ["latin"] });
+
+export const metadata: Metadata = {
+ metadataBase: new URL(process.env.NEXTAUTH_URL ?? "http://localhost:3000"),
+ title: "NeoBoard",
+ description:
+ "Open-source dashboards for Neo4j + PostgreSQL — the modern alternative to NeoDash",
+ icons: {
+ icon: "/logo.svg",
+ apple: "/logo.svg",
+ },
+ manifest: "/site.webmanifest",
+ openGraph: {
+ title: "NeoBoard",
+ description: "Open-source dashboards for Neo4j + PostgreSQL",
+ images: [{ url: "/og-image.svg", width: 1200, height: 630 }],
+ type: "website",
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "NeoBoard",
+ description: "Open-source dashboards for Neo4j + PostgreSQL",
+ images: ["/og-image.svg"],
+ },
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/src/components/__tests__/card-container-states.test.tsx b/app/src/components/__tests__/card-container-states.test.tsx
new file mode 100644
index 000000000..8041d2501
--- /dev/null
+++ b/app/src/components/__tests__/card-container-states.test.tsx
@@ -0,0 +1,435 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+
+/* ---------- mocks (must be declared before imports) ---------- */
+
+// Stub out heavy component-library and dynamic imports
+vi.mock("@neoboard/components", () => ({
+ Skeleton: ({ className }: { className?: string }) => (
+
+ ),
+ EmptyState: ({
+ title,
+ description,
+ icon,
+ }: {
+ title: string;
+ description?: string;
+ icon?: React.ReactNode;
+ }) => (
+
+ {title}
+ {description && {description} }
+ {icon}
+
+ ),
+ Alert: ({ children }: { children: React.ReactNode }) => {children}
,
+ AlertTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Button: ({
+ children,
+ ...rest
+ }: React.ButtonHTMLAttributes) => (
+ {children}
+ ),
+ Popover: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ PopoverTrigger: ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ ),
+ PopoverContent: ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ ),
+ ColumnMappingOverlay: () =>
,
+ substituteParams: (s: string) => s,
+ getChartOptions: () => [],
+}));
+
+vi.mock("next/dynamic", () => ({
+ default: () =>
+ function DynamicStub() {
+ return
;
+ },
+}));
+
+// Mock chart-renderer to avoid pulling chart deps
+vi.mock("@/components/chart-renderer", () => ({
+ ChartRenderer: () =>
,
+}));
+
+// Mock hooks
+const mockUseWidgetQuery = vi.fn();
+vi.mock("@/hooks/use-widget-query", () => ({
+ useWidgetQuery: (...args: unknown[]) => mockUseWidgetQuery(...args),
+}));
+
+vi.mock("@/hooks/use-click-action", () => ({
+ useClickAction: () => ({
+ handleChartClick: vi.fn(),
+ hasClickAction: false,
+ clickableColumns: [],
+ }),
+}));
+
+vi.mock("@/stores/parameter-store", () => ({
+ useParameterStore: (sel: (s: Record) => unknown) =>
+ sel({ parameters: {} }),
+ useParameterValues: () => ({}),
+}));
+
+vi.mock("@/lib/query/resolve-cache-options", () => ({
+ resolveCacheOptions: () => ({ staleTime: 0, gcTime: undefined }),
+}));
+
+vi.mock("@/lib/widget/card-utils", () => ({
+ extractColumnNames: () => [],
+ resolveStylingConfig: () => undefined,
+}));
+
+vi.mock("@/lib/widget/scroll-to-widget", () => ({
+ scrollAndHighlight: () => false,
+}));
+
+vi.mock("@/lib/query/data-transforms", () => ({
+ applyTransforms: (d: unknown) => d,
+}));
+
+/* ---------- import under test ---------- */
+import { CardContainer } from "../card-container";
+import type { DashboardWidget } from "@/lib/db/schema";
+
+/** Helper to create a minimal widget. */
+function makeWidget(overrides: Partial = {}): DashboardWidget {
+ return {
+ id: "w1",
+ chartType: "bar",
+ connectionId: "conn-1",
+ query: "MATCH (n) RETURN n.name AS name, count(*) AS value",
+ ...overrides,
+ };
+}
+
+describe("CardContainer", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ----- Missing connection -----
+
+ it('shows "No connection configured" when connectionId is empty', () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: true,
+ fetchStatus: "idle",
+ isError: false,
+ data: undefined,
+ missingParams: [],
+ });
+
+ render( );
+
+ expect(screen.getByText("No connection configured")).toBeDefined();
+ expect(
+ screen.getByText(
+ "Select a connection in the widget settings to start querying data.",
+ ),
+ ).toBeDefined();
+ // Should NOT show "Waiting for parameters"
+ expect(screen.queryByText(/Waiting for parameters/)).toBeNull();
+ });
+
+ // ----- Missing query -----
+
+ it('shows "No query configured" when query is empty', () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: true,
+ fetchStatus: "idle",
+ isError: false,
+ data: undefined,
+ missingParams: [],
+ });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("No query configured")).toBeDefined();
+ expect(
+ screen.getByText("Add a query in the widget settings."),
+ ).toBeDefined();
+ expect(screen.queryByText(/Waiting for parameters/)).toBeNull();
+ });
+
+ // ----- Missing parameters -----
+
+ it('shows "Waiting for parameters" only when connectionId and query are set but params are unresolved', () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: true,
+ fetchStatus: "idle",
+ isError: false,
+ data: undefined,
+ missingParams: ["region"],
+ });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/Waiting for parameters/)).toBeDefined();
+ // Parameter badge should be rendered
+ expect(screen.getByText("$param_region")).toBeDefined();
+ });
+
+ // ----- Loading state (query actively fetching) -----
+
+ it("shows loading skeleton when query is actively fetching", () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: true,
+ fetchStatus: "fetching",
+ isError: false,
+ data: undefined,
+ missingParams: [],
+ });
+
+ render( );
+
+ // Should render skeleton loaders (data-loading=true container)
+ const skeletons = screen.getAllByTestId("skeleton");
+ expect(skeletons.length).toBeGreaterThan(0);
+ });
+
+ // ----- Error state -----
+
+ it("shows error alert when query fails", () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: false,
+ fetchStatus: "idle",
+ isError: true,
+ error: new Error("Connection refused"),
+ data: undefined,
+ missingParams: [],
+ });
+
+ render( );
+
+ expect(screen.getByText("Query Failed")).toBeDefined();
+ expect(screen.getByText("Connection refused")).toBeDefined();
+ });
+
+ it("shows soft 'Server busy' state with Retry button on QueueFullError", async () => {
+ const { QueueFullError } = await import("@/lib/api/api-client");
+ const refetch = vi.fn();
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: false,
+ fetchStatus: "idle",
+ isError: true,
+ error: new QueueFullError("busy", 2000),
+ data: undefined,
+ missingParams: [],
+ refetch,
+ });
+
+ render( );
+
+ expect(screen.getByText("Server busy")).toBeDefined();
+ expect(
+ screen.getByText(/server is handling too many queries/i),
+ ).toBeDefined();
+ expect(screen.queryByText("Query Failed")).toBeNull();
+ screen.getByRole("button", { name: /retry/i }).click();
+ expect(refetch).toHaveBeenCalled();
+ });
+
+ it("shows 'Server timed out' state with Retry button on ClientQueueTimeoutError", async () => {
+ const { ClientQueueTimeoutError } = await import("@/lib/api/api-client");
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: false,
+ fetchStatus: "idle",
+ isError: true,
+ error: new ClientQueueTimeoutError("timeout", 5000),
+ data: undefined,
+ missingParams: [],
+ refetch: vi.fn(),
+ });
+
+ render( );
+
+ expect(screen.getByText("Server timed out")).toBeDefined();
+ expect(screen.getByText(/waited too long in the queue/i)).toBeDefined();
+ expect(screen.queryByText("Query Failed")).toBeNull();
+ });
+
+ // ----- Successful render -----
+
+ it("renders chart when query returns data", () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: false,
+ fetchStatus: "idle",
+ isError: false,
+ data: {
+ data: [{ name: "Alice", value: 10 }],
+ resultId: "r1",
+ },
+ missingParams: [],
+ });
+
+ render( );
+
+ expect(screen.getByTestId("chart-renderer")).toBeDefined();
+ });
+
+ // ----- Priority: connectionId check comes before parameter check -----
+
+ it("prioritises missing connection message over missing parameters", () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: true,
+ fetchStatus: "idle",
+ isError: false,
+ data: undefined,
+ missingParams: ["region"],
+ });
+
+ render(
+ ,
+ );
+
+ // Connection message should win over parameter message
+ expect(screen.getByText("No connection configured")).toBeDefined();
+ expect(screen.queryByText(/Waiting for parameters/)).toBeNull();
+ });
+
+ // ----- Manual run overlay -----
+
+ it("shows manual run overlay when manualRun is enabled and query has not been run", () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: true,
+ fetchStatus: "idle",
+ isError: false,
+ data: undefined,
+ missingParams: [],
+ });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("manual-run-overlay")).toBeDefined();
+ expect(screen.getByText("Query execution is paused.")).toBeDefined();
+ expect(screen.getByRole("button", { name: /run query/i })).toBeDefined();
+ });
+
+ // ----- No data state -----
+
+ it('shows "No data" when query returns null data', () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: false,
+ fetchStatus: "idle",
+ isError: false,
+ data: null,
+ missingParams: [],
+ });
+
+ render( );
+
+ expect(screen.getByText("No data")).toBeDefined();
+ });
+
+ // ----- Parameter-select widget (no query) -----
+
+ it("renders chart directly for parameter-select widgets without querying", () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: false,
+ fetchStatus: "idle",
+ isError: false,
+ data: null,
+ missingParams: [],
+ });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("chart-renderer")).toBeDefined();
+ });
+
+ // ----- Truncation warning -----
+
+ it("shows truncation warning with the dynamic rowLimit when data is truncated", () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: false,
+ fetchStatus: "idle",
+ isError: false,
+ data: {
+ data: [{ name: "Alice", value: 10 }],
+ resultId: "r1",
+ truncated: true,
+ rowLimit: 5000,
+ },
+ missingParams: [],
+ });
+
+ render( );
+
+ expect(screen.getByText(/Showing first 5,000 rows/)).toBeDefined();
+ });
+
+ it("reflects a custom per-connection rowLimit in the truncation warning", () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: false,
+ fetchStatus: "idle",
+ isError: false,
+ data: {
+ data: [{ name: "Alice", value: 10 }],
+ resultId: "r1",
+ truncated: true,
+ rowLimit: 25000,
+ },
+ missingParams: [],
+ });
+
+ render( );
+
+ expect(screen.getByText(/Showing first 25,000 rows/)).toBeDefined();
+ });
+
+ it("does not show truncation warning when data is not truncated", () => {
+ mockUseWidgetQuery.mockReturnValue({
+ isPending: false,
+ fetchStatus: "idle",
+ isError: false,
+ data: {
+ data: [{ name: "Alice", value: 10 }],
+ resultId: "r1",
+ truncated: false,
+ rowLimit: 5000,
+ },
+ missingParams: [],
+ });
+
+ render( );
+
+ expect(screen.queryByText(/Showing first .* rows/)).toBeNull();
+ });
+});
diff --git a/app/src/components/__tests__/card-container.test.tsx b/app/src/components/__tests__/card-container.test.tsx
new file mode 100644
index 000000000..faaf35b2f
--- /dev/null
+++ b/app/src/components/__tests__/card-container.test.tsx
@@ -0,0 +1,270 @@
+/**
+ * CardContainer tests — focused on the widgetIdSuffix prop that prevents
+ * graph store conflicts when two CardContainers render the same widget
+ * (e.g. normal view + fullscreen dialog).
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { DashboardWidget } from "@/lib/db/schema";
+
+// ── Capture ChartRenderer props to verify effectiveWidgetId ───────────
+let capturedChartProps: Record = {};
+
+vi.mock("@/components/chart-renderer", () => ({
+ ChartRenderer: (props: Record) => {
+ capturedChartProps = props;
+ return
;
+ },
+}));
+
+vi.mock("@/hooks/use-widget-query", () => ({
+ useWidgetQuery: () => ({
+ isPending: false,
+ isError: false,
+ data: null,
+ fetchStatus: "idle",
+ missingParams: [],
+ }),
+}));
+
+vi.mock("@/hooks/use-click-action", () => ({
+ useClickAction: () => ({
+ handleChartClick: undefined,
+ hasClickAction: false,
+ clickableColumns: [],
+ }),
+}));
+
+vi.mock("@/stores/parameter-store", () => ({
+ useParameterValues: () => ({}),
+}));
+
+vi.mock("@/lib/plugin/chart-helpers", () => ({
+ getChartConfig: (type: string) => {
+ if (type === "bar" || type === "markdown") {
+ return {
+ type,
+ label: type,
+ transform: (d: unknown) => d,
+ transformWithMapping: (d: unknown) => d,
+ validate: () => null,
+ capabilities: {
+ supportsClickAction: true,
+ supportsStyling: false,
+ isECharts: false,
+ requiresQuery: true,
+ },
+ };
+ }
+ return null;
+ },
+ supportsColumnMapping: () => false,
+}));
+
+vi.mock("@/lib/query/resolve-cache-options", () => ({
+ resolveCacheOptions: () => ({ staleTime: 0, gcTime: 0 }),
+}));
+
+vi.mock("@/lib/widget/scroll-to-widget", () => ({
+ scrollAndHighlight: () => false,
+}));
+
+vi.mock("@neoboard/components", () => ({
+ Skeleton: () =>
,
+ Alert: ({ children }: { children: React.ReactNode }) => {children}
,
+ AlertDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Button: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ }) => {children} ,
+ EmptyState: ({
+ title,
+ description,
+ }: {
+ title: string;
+ description?: string;
+ }) => (
+
+ {title}
+ {description && {description} }
+
+ ),
+ ColumnMappingOverlay: () => null,
+ substituteParams: (s: string) => s,
+ Popover: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ PopoverTrigger: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ PopoverContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock("@/lib/query/data-transforms", () => ({
+ applyTransforms: (data: unknown) => data,
+}));
+
+vi.mock("@/lib/widget/card-utils", () => ({
+ extractColumnNames: () => [],
+ resolveStylingConfig: () => undefined,
+}));
+
+// Import after mocks
+import { CardContainer } from "../card-container";
+
+function createWidget(overrides?: Partial): DashboardWidget {
+ return {
+ id: "widget-123",
+ chartType: "markdown",
+ connectionId: "conn-1",
+ query: "",
+ settings: {
+ chartOptions: { content: "hello" },
+ },
+ ...overrides,
+ };
+}
+
+function renderWithProviders(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+ {ui} ,
+ );
+}
+
+describe("CardContainer", () => {
+ beforeEach(() => {
+ capturedChartProps = {};
+ vi.clearAllMocks();
+ });
+
+ describe("widgetIdSuffix prop", () => {
+ it("passes widget.id as widgetId in meta when widgetIdSuffix is not provided", () => {
+ const widget = createWidget();
+ renderWithProviders( );
+
+ const meta = capturedChartProps.meta as { widgetId?: string };
+ expect(meta?.widgetId).toBe("widget-123");
+ });
+
+ it("appends suffix to widgetId when widgetIdSuffix is provided", () => {
+ const widget = createWidget();
+ renderWithProviders(
+ ,
+ );
+
+ const meta = capturedChartProps.meta as { widgetId?: string };
+ expect(meta?.widgetId).toBe("widget-123--fullscreen");
+ });
+
+ it("uses double-dash separator between widget id and suffix", () => {
+ const widget = createWidget({ id: "w-99" });
+ renderWithProviders(
+ ,
+ );
+
+ const meta = capturedChartProps.meta as { widgetId?: string };
+ expect(meta?.widgetId).toBe("w-99--preview");
+ });
+
+ it("passes original widget.id when widgetIdSuffix is empty string", () => {
+ // Empty string is falsy, so effectiveWidgetId should be widget.id
+ const widget = createWidget();
+ renderWithProviders( );
+
+ const meta = capturedChartProps.meta as { widgetId?: string };
+ expect(meta?.widgetId).toBe("widget-123");
+ });
+ });
+
+ describe("preview data path with widgetIdSuffix", () => {
+ it("passes effectiveWidgetId through meta when rendering with previewData", () => {
+ const widget = createWidget({ chartType: "bar" });
+ const previewData = [{ name: "A", value: 1 }];
+ renderWithProviders(
+ ,
+ );
+
+ const meta = capturedChartProps.meta as { widgetId?: string };
+ expect(meta?.widgetId).toBe("widget-123--fullscreen");
+ });
+ });
+
+ describe("unknown chart type", () => {
+ it("shows empty state for unknown chart types", () => {
+ const widget = createWidget({ chartType: "nonexistent" });
+ renderWithProviders( );
+
+ expect(screen.getByText("Unknown chart type")).toBeInTheDocument();
+ });
+ });
+
+ describe("content-only widget paths", () => {
+ it("renders markdown widget without querying", () => {
+ const widget = createWidget({
+ chartType: "markdown",
+ settings: { chartOptions: { content: "# Hello" } },
+ });
+ renderWithProviders( );
+
+ expect(screen.getByTestId("chart-renderer")).toBeInTheDocument();
+ });
+
+ it("passes effectiveWidgetId in meta for content-only widgets", () => {
+ const widget = createWidget({
+ chartType: "markdown",
+ settings: { chartOptions: { content: "test" } },
+ });
+ renderWithProviders(
+ ,
+ );
+
+ const meta = capturedChartProps.meta as { widgetId?: string };
+ expect(meta?.widgetId).toBe("widget-123--preview");
+ });
+ });
+
+ describe("preview data validation", () => {
+ it("renders chart when preview data passes validation", () => {
+ const widget = createWidget({ chartType: "bar" });
+ const previewData = [{ label: "A", count: 10 }];
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("chart-renderer")).toBeInTheDocument();
+ });
+ });
+
+ describe("form widget path", () => {
+ it("renders chart for form widgets without querying", () => {
+ // Need to add "form" to the mock chart-helpers
+ const widget = createWidget({
+ chartType: "bar",
+ settings: { chartOptions: {} },
+ });
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("chart-renderer")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/app/src/components/__tests__/chart-error-boundary-unit.test.tsx b/app/src/components/__tests__/chart-error-boundary-unit.test.tsx
new file mode 100644
index 000000000..5fb5bcf53
--- /dev/null
+++ b/app/src/components/__tests__/chart-error-boundary-unit.test.tsx
@@ -0,0 +1,50 @@
+import { describe, it, expect, vi, afterAll } from "vitest";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import { ChartErrorBoundary } from "../chart-error-boundary";
+
+const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+
+function ThrowingChild(): React.JSX.Element {
+ throw new Error("test explosion");
+}
+
+function GoodChild() {
+ return OK
;
+}
+
+describe("ChartErrorBoundary", () => {
+ afterAll(() => consoleError.mockRestore());
+
+ it("renders children when no error", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("good-child")).toBeDefined();
+ });
+
+ it("renders fallback UI when child throws", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Chart failed to render")).toBeDefined();
+ expect(screen.getByText("test explosion")).toBeDefined();
+ });
+
+ it("logs error with chart type", () => {
+ render(
+
+
+ ,
+ );
+ expect(consoleError).toHaveBeenCalledWith(
+ expect.stringContaining("[ChartErrorBoundary] sankey crashed:"),
+ expect.any(Error),
+ expect.anything(),
+ );
+ });
+});
diff --git a/app/src/components/__tests__/chart-error-boundary.test.tsx b/app/src/components/__tests__/chart-error-boundary.test.tsx
new file mode 100644
index 000000000..945476a08
--- /dev/null
+++ b/app/src/components/__tests__/chart-error-boundary.test.tsx
@@ -0,0 +1,114 @@
+import { describe, it, expect, vi, afterAll } from "vitest";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+
+// Mock @neoboard/components to avoid pulling in ECharts
+vi.mock("@neoboard/components", () => ({
+ Skeleton: ({ className }: { className?: string }) => (
+
+ ),
+ EmptyState: ({
+ title,
+ description,
+ }: {
+ title: string;
+ description?: string;
+ }) => (
+
+ {title}
+ {description && {description} }
+
+ ),
+ JsonViewer: () =>
,
+ MarkdownWidget: () =>
,
+ IframeWidget: () =>
,
+ getChartOptions: () => [],
+}));
+
+// Mock next/dynamic to just render children synchronously
+vi.mock("next/dynamic", () => ({
+ default: () => {
+ return function DynamicStub() {
+ return
;
+ };
+ },
+}));
+
+vi.mock("@/lib/shared/normalize-value", () => ({
+ normalizeValue: (v: unknown) => v,
+}));
+vi.mock("@/components/parameter-widget-renderer", () => ({
+ ParameterWidgetRenderer: () =>
,
+}));
+vi.mock("@/components/graph-exploration-wrapper", () => ({
+ GraphExplorationWrapper: () =>
,
+}));
+vi.mock("@/components/form-widget-renderer", () => ({
+ FormWidgetRenderer: () =>
,
+}));
+
+// Suppress console.error from the error boundary during tests
+const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+
+import { ChartRenderer } from "../chart-renderer";
+
+describe("ChartRenderer error boundary", () => {
+ afterAll(() => {
+ consoleError.mockRestore();
+ });
+
+ it("renders fallback when a chart throws during render", () => {
+ // Force a render error by passing data that will cause JSON.stringify to throw
+ const circular: Record = {};
+ circular.self = circular;
+
+ // The table renderer will try to process this — but we need something
+ // that actually throws. Let's use a getter that throws.
+ const badData = [
+ new Proxy(
+ {},
+ {
+ get() {
+ throw new Error("Boom!");
+ },
+ ownKeys() {
+ throw new Error("Boom!");
+ },
+ },
+ ),
+ ];
+
+ render(
+ [0]["type"]}
+ data={badData}
+ />,
+ );
+
+ expect(screen.getByText("Chart failed to render")).toBeDefined();
+ expect(screen.getByText("Boom!")).toBeDefined();
+ });
+
+ it("renders chart normally when no error occurs", () => {
+ render(
+ [0]["type"]}
+ data={{ hello: "world" }}
+ />,
+ );
+
+ // JSON viewer should render (mocked)
+ expect(screen.getByTestId("json-viewer")).toBeDefined();
+ });
+
+ it("renders unknown chart type as empty state (not error boundary)", () => {
+ render(
+ [0]["type"]}
+ data={null}
+ />,
+ );
+
+ expect(screen.getByText("Unknown chart type")).toBeDefined();
+ });
+});
diff --git a/app/src/components/__tests__/chart-renderer.test.tsx b/app/src/components/__tests__/chart-renderer.test.tsx
new file mode 100644
index 000000000..945476a08
--- /dev/null
+++ b/app/src/components/__tests__/chart-renderer.test.tsx
@@ -0,0 +1,114 @@
+import { describe, it, expect, vi, afterAll } from "vitest";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+
+// Mock @neoboard/components to avoid pulling in ECharts
+vi.mock("@neoboard/components", () => ({
+ Skeleton: ({ className }: { className?: string }) => (
+
+ ),
+ EmptyState: ({
+ title,
+ description,
+ }: {
+ title: string;
+ description?: string;
+ }) => (
+
+ {title}
+ {description && {description} }
+
+ ),
+ JsonViewer: () =>
,
+ MarkdownWidget: () =>
,
+ IframeWidget: () =>
,
+ getChartOptions: () => [],
+}));
+
+// Mock next/dynamic to just render children synchronously
+vi.mock("next/dynamic", () => ({
+ default: () => {
+ return function DynamicStub() {
+ return
;
+ };
+ },
+}));
+
+vi.mock("@/lib/shared/normalize-value", () => ({
+ normalizeValue: (v: unknown) => v,
+}));
+vi.mock("@/components/parameter-widget-renderer", () => ({
+ ParameterWidgetRenderer: () =>
,
+}));
+vi.mock("@/components/graph-exploration-wrapper", () => ({
+ GraphExplorationWrapper: () =>
,
+}));
+vi.mock("@/components/form-widget-renderer", () => ({
+ FormWidgetRenderer: () =>
,
+}));
+
+// Suppress console.error from the error boundary during tests
+const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+
+import { ChartRenderer } from "../chart-renderer";
+
+describe("ChartRenderer error boundary", () => {
+ afterAll(() => {
+ consoleError.mockRestore();
+ });
+
+ it("renders fallback when a chart throws during render", () => {
+ // Force a render error by passing data that will cause JSON.stringify to throw
+ const circular: Record = {};
+ circular.self = circular;
+
+ // The table renderer will try to process this — but we need something
+ // that actually throws. Let's use a getter that throws.
+ const badData = [
+ new Proxy(
+ {},
+ {
+ get() {
+ throw new Error("Boom!");
+ },
+ ownKeys() {
+ throw new Error("Boom!");
+ },
+ },
+ ),
+ ];
+
+ render(
+ [0]["type"]}
+ data={badData}
+ />,
+ );
+
+ expect(screen.getByText("Chart failed to render")).toBeDefined();
+ expect(screen.getByText("Boom!")).toBeDefined();
+ });
+
+ it("renders chart normally when no error occurs", () => {
+ render(
+ [0]["type"]}
+ data={{ hello: "world" }}
+ />,
+ );
+
+ // JSON viewer should render (mocked)
+ expect(screen.getByTestId("json-viewer")).toBeDefined();
+ });
+
+ it("renders unknown chart type as empty state (not error boundary)", () => {
+ render(
+ [0]["type"]}
+ data={null}
+ />,
+ );
+
+ expect(screen.getByText("Unknown chart type")).toBeDefined();
+ });
+});
diff --git a/app/src/components/__tests__/dashboard-container-branches.test.tsx b/app/src/components/__tests__/dashboard-container-branches.test.tsx
new file mode 100644
index 000000000..4dd626214
--- /dev/null
+++ b/app/src/components/__tests__/dashboard-container-branches.test.tsx
@@ -0,0 +1,686 @@
+/**
+ * DashboardContainer — branch coverage for buildActions, CSV export,
+ * fullscreen toggle, template sync banner, refresh button, and
+ * parameter bar. Complements dashboard-container-dblclick which focuses
+ * on the onDoubleClick handler.
+ */
+import React from "react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, act } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type {
+ DashboardPage,
+ DashboardWidget,
+ WidgetTemplate,
+} from "@/lib/db/schema";
+
+/* ---------- mocks ---------- */
+
+// Capture the actions array passed to WidgetCard for assertions.
+const widgetCardProps: Array> = [];
+
+vi.mock("@neoboard/components", () => ({
+ WidgetCard: ({
+ children,
+ title,
+ actions,
+ onRefresh,
+ headerExtra,
+ }: {
+ children: React.ReactNode;
+ title: string;
+ actions?: Array<{ label: string; onClick?: () => void }>;
+ onRefresh?: () => void;
+ headerExtra?: React.ReactNode;
+ }) => {
+ widgetCardProps.push({ title, actions, onRefresh });
+ return (
+
+
{headerExtra}
+ {onRefresh && (
+
+ refresh
+
+ )}
+ {actions?.map((a) => (
+
a.onClick?.()}
+ >
+ {a.label}
+
+ ))}
+ {children}
+
+ );
+ },
+ EmptyState: ({
+ title,
+ description,
+ }: {
+ title: string;
+ description?: string;
+ }) => (
+
+ {title}
+ {description && {description} }
+
+ ),
+ DashboardGrid: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Dialog: ({
+ children,
+ open,
+ onOpenChange,
+ }: {
+ children: React.ReactNode;
+ open: boolean;
+ onOpenChange?: (open: boolean) => void;
+ }) =>
+ open ? (
+
+ onOpenChange?.(false)}
+ >
+ close
+
+ {children}
+
+ ) : null,
+ DialogContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Button: ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) => (
+ {children}
+ ),
+ ParameterBar: ({
+ children,
+ onReset,
+ }: {
+ children: React.ReactNode;
+ onReset: () => void;
+ }) => (
+
+
+ reset
+
+ {children}
+
+ ),
+ CrossFilterTag: ({
+ field,
+ value,
+ onRemove,
+ onClick,
+ tooltip,
+ }: {
+ field: string;
+ value: string;
+ onRemove: () => void;
+ onClick?: () => void;
+ tooltip?: string;
+ }) => (
+
+ {value}
+
+ click
+
+
+ x
+
+
+ ),
+ AlertDialog: ({
+ children,
+ open,
+ }: {
+ children: React.ReactNode;
+ open: boolean;
+ }) =>
+ open ? (
+
+ {children}
+
+ ) : null,
+ AlertDialogAction: ({
+ children,
+ onClick,
+ }: React.PropsWithChildren<{ onClick?: () => void }>) => (
+
+ {children}
+
+ ),
+ AlertDialogCancel: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogFooter: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ buildCsvString: vi.fn((rows: unknown[]) => `CSV(${rows.length})`),
+ triggerDownload: vi.fn(),
+ buildExportFilename: vi.fn((title: string, ext: string) => `${title}.${ext}`),
+}));
+
+vi.mock("@/components/card-container", () => ({
+ CardContainer: ({ widgetIdSuffix }: { widgetIdSuffix?: string }) => (
+
+ ),
+}));
+
+vi.mock("@/lib/widget/interpolate-title", () => ({
+ interpolateTitle: (title: string) => title,
+}));
+
+const mockBuildExportData = vi.fn();
+vi.mock("@/lib/widget/card-utils", () => ({
+ buildExportData: (...args: unknown[]) => mockBuildExportData(...args),
+}));
+
+const mockIsTemplateOutdated = vi.fn();
+vi.mock("@/lib/widget/widget-utils", () => ({
+ getWidgetDisplayTitle: (w: DashboardWidget) =>
+ (w.settings?.title as string) || w.chartType,
+ isWidgetTemplateOutdated: (w: unknown, map: unknown) =>
+ mockIsTemplateOutdated(w, map),
+}));
+
+const mockIsDataWidget = vi.fn();
+vi.mock("@/lib/widget/widget-actions", () => ({
+ isDataWidget: (t: string) => mockIsDataWidget(t),
+}));
+
+const parametersState = {
+ parameters: {} as Record<
+ string,
+ { field: string; value: unknown; source?: string; sourceWidgetId?: string }
+ >,
+ clearParameter: vi.fn(),
+ clearAll: vi.fn(),
+};
+
+vi.mock("@/stores/parameter-store", () => ({
+ useParameterStore: (sel: (s: typeof parametersState) => unknown) =>
+ sel(parametersState),
+ useParameterValues: () => ({ foo: "bar" }),
+}));
+
+vi.mock("@/lib/parameter/format-parameter-value", () => ({
+ formatParameterValue: (v: unknown) => String(v),
+ filterParentParams: (entries: [string, unknown][]) => entries,
+}));
+
+const mockShouldShowRefresh = vi.fn();
+vi.mock("@/lib/query/resolve-cache-options", () => ({
+ shouldShowRefreshButton: (opts: unknown) => mockShouldShowRefresh(opts),
+}));
+
+/* ---------- import under test ---------- */
+const { DashboardContainer } = await import("../dashboard-container");
+const {
+ buildCsvString: mockBuildCsv,
+ triggerDownload: mockTriggerDownload,
+ buildExportFilename: mockBuildFilename,
+} = await import("@neoboard/components");
+
+/* ---------- helpers ---------- */
+
+function makeWidget(overrides: Partial = {}): DashboardWidget {
+ return {
+ id: "w-1",
+ chartType: "bar",
+ connectionId: "conn-1",
+ query: "MATCH (n) RETURN n",
+ settings: { title: "Test Widget" },
+ ...overrides,
+ };
+}
+
+function makePage(widgets: DashboardWidget[] = [makeWidget()]): DashboardPage {
+ return {
+ id: "page-1",
+ title: "My Dashboard",
+ widgets,
+ gridLayout: widgets.map((w, i) => ({
+ i: w.id,
+ x: 0,
+ y: i * 2,
+ w: 12,
+ h: 2,
+ })),
+ };
+}
+
+function renderWithProviders(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return {
+ queryClient,
+ ...render(
+ {ui} ,
+ ),
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ widgetCardProps.length = 0;
+ parametersState.parameters = {};
+ mockIsDataWidget.mockReturnValue(true);
+ mockIsTemplateOutdated.mockReturnValue(false);
+ mockShouldShowRefresh.mockReturnValue(false);
+ mockBuildExportData.mockReturnValue([]);
+});
+
+/* ---------- tests ---------- */
+
+describe("DashboardContainer — buildActions", () => {
+ it("adds Export CSV only for data widgets", () => {
+ mockIsDataWidget.mockReturnValue(false);
+ renderWithProviders(
+ ,
+ );
+ expect(screen.queryByTestId("action-export-csv")).toBeNull();
+ });
+
+ it("adds Export CSV for data widgets", () => {
+ mockIsDataWidget.mockReturnValue(true);
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByTestId("action-export-csv")).toBeDefined();
+ });
+
+ it("omits Edit/Duplicate/Remove when editable=false", () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.queryByTestId("action-edit-widget")).toBeNull();
+ expect(screen.queryByTestId("action-duplicate")).toBeNull();
+ expect(screen.queryByTestId("action-remove")).toBeNull();
+ });
+
+ it("includes Edit/Duplicate/Remove when editable=true and callbacks provided", () => {
+ const onRemoveWidget = vi.fn();
+ const onDuplicateWidget = vi.fn();
+ const onEditWidget = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ fireEvent.click(screen.getByTestId("action-edit-widget"));
+ fireEvent.click(screen.getByTestId("action-duplicate"));
+ fireEvent.click(screen.getByTestId("action-remove"));
+ expect(onEditWidget).toHaveBeenCalledTimes(1);
+ expect(onDuplicateWidget).toHaveBeenCalledWith("w-1");
+ expect(onRemoveWidget).toHaveBeenCalledWith("w-1");
+ });
+
+ it("includes 'Save to Widget Lab' when onSaveAsTemplate is provided", () => {
+ const onSaveAsTemplate = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ fireEvent.click(screen.getByTestId("action-save-to-widget-lab"));
+ expect(onSaveAsTemplate).toHaveBeenCalledTimes(1);
+ });
+
+ it("adds Sync/Detach actions only when widget.templateId + outdated", () => {
+ mockIsTemplateOutdated.mockReturnValue(true);
+ const onSyncWidget = vi.fn();
+ const onDetachWidget = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByTestId("action-sync-with-template")).toBeDefined();
+ expect(screen.getByTestId("action-detach-from-template")).toBeDefined();
+ });
+
+ it("does NOT add Sync action when template is up-to-date", () => {
+ mockIsTemplateOutdated.mockReturnValue(false);
+ renderWithProviders(
+ ,
+ );
+ expect(screen.queryByTestId("action-sync-with-template")).toBeNull();
+ expect(screen.getByTestId("action-detach-from-template")).toBeDefined();
+ });
+
+ it("returns no actions when none apply (non-data widget, no callbacks, not editable)", () => {
+ mockIsDataWidget.mockReturnValue(false);
+ renderWithProviders(
+ ,
+ );
+ const props = widgetCardProps[0];
+ expect(props.actions).toBeUndefined();
+ });
+});
+
+describe("DashboardContainer — parameter bar", () => {
+ it("renders ParameterBar when parameters exist and showParameterBar=true", () => {
+ parametersState.parameters = {
+ status: { field: "status", value: "active" },
+ };
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByTestId("parameter-bar")).toBeDefined();
+ expect(screen.getByTestId("filter-tag-status")).toBeDefined();
+ });
+
+ it("does NOT render ParameterBar when showParameterBar=false", () => {
+ parametersState.parameters = {
+ status: { field: "status", value: "active" },
+ };
+ renderWithProviders(
+ ,
+ );
+ expect(screen.queryByTestId("parameter-bar")).toBeNull();
+ });
+
+ it("does NOT render ParameterBar when no parameters exist", () => {
+ renderWithProviders( );
+ expect(screen.queryByTestId("parameter-bar")).toBeNull();
+ });
+
+ it("calls clearParameter when a tag's remove button is clicked", () => {
+ parametersState.parameters = {
+ status: { field: "status", value: "active" },
+ };
+ renderWithProviders( );
+ fireEvent.click(screen.getByTestId("tag-remove-status"));
+ expect(parametersState.clearParameter).toHaveBeenCalledWith("status");
+ });
+
+ it("calls clearAll when the ParameterBar reset is clicked", () => {
+ parametersState.parameters = {
+ status: { field: "status", value: "active" },
+ };
+ renderWithProviders( );
+ fireEvent.click(screen.getByTestId("reset-all"));
+ expect(parametersState.clearAll).toHaveBeenCalled();
+ });
+
+ it("tag click scrolls to source widget when sourceWidgetId is set", () => {
+ parametersState.parameters = {
+ status: {
+ field: "status",
+ value: "active",
+ source: "Widget A",
+ sourceWidgetId: "src-1",
+ },
+ };
+ const scrollIntoView = vi.fn();
+ const origQuerySelector = document.querySelector.bind(document);
+ const spy = vi
+ .spyOn(document, "querySelector")
+ .mockImplementation((sel: string) => {
+ if (sel.includes("src-1"))
+ return { scrollIntoView } as unknown as Element;
+ return origQuerySelector(sel);
+ });
+ renderWithProviders( );
+ fireEvent.click(screen.getByTestId("tag-click-status"));
+ expect(scrollIntoView).toHaveBeenCalledWith({
+ behavior: "smooth",
+ block: "center",
+ });
+ spy.mockRestore();
+ });
+});
+
+describe("DashboardContainer — CSV export", () => {
+ it("triggers CSV download with widget data when rows are present", () => {
+ mockIsDataWidget.mockReturnValue(true);
+ mockBuildExportData.mockReturnValue([{ a: 1 }, { a: 2 }]);
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByTestId("action-export-csv"));
+
+ expect(mockBuildExportData).toHaveBeenCalled();
+ expect(mockBuildCsv).toHaveBeenCalledWith([{ a: 1 }, { a: 2 }]);
+ expect(mockBuildFilename).toHaveBeenCalledWith(
+ "Test Widget",
+ "csv",
+ "My Dashboard",
+ );
+ expect(mockTriggerDownload).toHaveBeenCalledWith(
+ "CSV(2)",
+ "Test Widget.csv",
+ );
+ });
+
+ it("does NOT trigger download when exportData is empty", () => {
+ mockIsDataWidget.mockReturnValue(true);
+ mockBuildExportData.mockReturnValue([]);
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByTestId("action-export-csv"));
+
+ expect(mockBuildCsv).not.toHaveBeenCalled();
+ expect(mockTriggerDownload).not.toHaveBeenCalled();
+ });
+
+ it("falls back to chartType when widget has no title for the filename", () => {
+ mockIsDataWidget.mockReturnValue(true);
+ mockBuildExportData.mockReturnValue([{ a: 1 }]);
+ renderWithProviders(
+ ,
+ );
+ fireEvent.click(screen.getByTestId("action-export-csv"));
+ expect(mockBuildFilename).toHaveBeenCalledWith(
+ "pie",
+ "csv",
+ "My Dashboard",
+ );
+ });
+});
+
+describe("DashboardContainer — refresh + fullscreen + sync dialogs", () => {
+ it("renders onRefresh handler only when shouldShowRefreshButton=true", () => {
+ mockShouldShowRefresh.mockReturnValue(false);
+ renderWithProviders( );
+ const props = widgetCardProps[0];
+ expect(props.onRefresh).toBeUndefined();
+ });
+
+ it("shows a refresh button when shouldShowRefreshButton=true, and invalidates queries on click", () => {
+ mockShouldShowRefresh.mockReturnValue(true);
+ const { queryClient } = renderWithProviders(
+ ,
+ );
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+ fireEvent.click(screen.getByTestId("widget-refresh"));
+ expect(invalidateSpy).toHaveBeenCalled();
+ });
+
+ it("opens the fullscreen dialog when the Maximize button is clicked", () => {
+ renderWithProviders( );
+ // Maximize button is the only icon-only button with sr-only text "Fullscreen"
+ const fullscreenBtn = screen.getByText("Fullscreen").closest("button");
+ expect(fullscreenBtn).not.toBeNull();
+ act(() => {
+ fireEvent.click(fullscreenBtn!);
+ });
+ expect(screen.getByTestId("fullscreen-dialog")).toBeDefined();
+ // Dialog title reflects the widget title
+ expect(screen.getByTestId("fullscreen-title").textContent).toBe(
+ "Test Widget",
+ );
+ });
+
+ it("closes the fullscreen dialog and clears the pending ready timer", () => {
+ vi.useFakeTimers();
+ try {
+ renderWithProviders( );
+ 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(
+ ,
+ );
+ 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", () => {
+ vi.useFakeTimers();
+ try {
+ const { unmount } = renderWithProviders(
+ ,
+ );
+ 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();
+ renderWithProviders(
+ ,
+ );
+ // The outdated button has sr-only text "Template update available"
+ const outdatedBtn = screen
+ .getByText("Template update available")
+ .closest("button");
+ expect(outdatedBtn).not.toBeNull();
+ act(() => {
+ fireEvent.click(outdatedBtn!);
+ });
+ expect(screen.getByTestId("sync-dialog")).toBeDefined();
+ // Confirming calls onSyncWidget
+ fireEvent.click(screen.getByTestId("confirm-sync"));
+ expect(onSyncWidget).toHaveBeenCalledTimes(1);
+ });
+
+ it("clicking Sync confirm without onSyncWidget still closes dialog", () => {
+ mockIsTemplateOutdated.mockReturnValue(true);
+ renderWithProviders(
+ ,
+ );
+ // With no onSyncWidget we can't open via the action menu (it wouldn't exist),
+ // but the outdated header button still opens the dialog.
+ const btn = screen.getByText("Template update available").closest("button");
+ act(() => {
+ fireEvent.click(btn!);
+ });
+ expect(screen.getByTestId("sync-dialog")).toBeDefined();
+ // Confirm — must not throw and must close
+ fireEvent.click(screen.getByTestId("confirm-sync"));
+ });
+});
diff --git a/app/src/components/__tests__/dashboard-container-dblclick.test.tsx b/app/src/components/__tests__/dashboard-container-dblclick.test.tsx
new file mode 100644
index 000000000..97b9e579d
--- /dev/null
+++ b/app/src/components/__tests__/dashboard-container-dblclick.test.tsx
@@ -0,0 +1,260 @@
+/**
+ * DashboardContainer — double-click to edit widget.
+ *
+ * Tests the onDoubleClick handler added in the widget editor UX PR.
+ * The handler should only fire when editable=true AND actions.onEditWidget
+ * is provided.
+ */
+import React from "react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { DashboardPage, DashboardWidget } from "@/lib/db/schema";
+
+// ── Mocks ──────────────────────────────────────────────────────────────
+
+vi.mock("@neoboard/components", () => ({
+ WidgetCard: ({
+ children,
+ title,
+ }: {
+ children: React.ReactNode;
+ title: string;
+ }) => (
+
+ {children}
+
+ ),
+ EmptyState: ({
+ title,
+ description,
+ }: {
+ title: string;
+ description?: string;
+ }) => (
+
+ {title}
+ {description && {description} }
+
+ ),
+ DashboardGrid: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Dialog: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ DialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Button: ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) => (
+ {children}
+ ),
+ ParameterBar: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CrossFilterTag: () =>
,
+ AlertDialog: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogAction: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogCancel: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogFooter: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ buildCsvString: () => "",
+ triggerDownload: vi.fn(),
+ buildExportFilename: () => "export.csv",
+}));
+
+vi.mock("@/components/card-container", () => ({
+ CardContainer: () =>
,
+}));
+
+vi.mock("@/lib/widget/interpolate-title", () => ({
+ interpolateTitle: (title: string) => title,
+}));
+
+vi.mock("@/lib/widget/card-utils", () => ({
+ buildExportData: () => [],
+}));
+
+vi.mock("@/lib/widget/widget-utils", () => ({
+ getWidgetDisplayTitle: (w: DashboardWidget) =>
+ (w.settings?.title as string) || w.chartType,
+ isWidgetTemplateOutdated: () => false,
+}));
+
+vi.mock("@/lib/widget/widget-actions", () => ({
+ isDataWidget: () => true,
+}));
+
+vi.mock("@/stores/parameter-store", () => ({
+ useParameterStore: (sel: (s: Record) => unknown) =>
+ sel({
+ parameters: {},
+ clearParameter: vi.fn(),
+ clearAll: vi.fn(),
+ }),
+ useParameterValues: () => ({}),
+}));
+
+vi.mock("@/lib/parameter/format-parameter-value", () => ({
+ formatParameterValue: (v: unknown) => String(v),
+ filterParentParams: (entries: [string, unknown][]) => entries,
+}));
+
+vi.mock("@/lib/query/resolve-cache-options", () => ({
+ shouldShowRefreshButton: () => false,
+}));
+
+// Import the component after mocks
+const { DashboardContainer } = await import("../dashboard-container");
+
+// ── Helpers ────────────────────────────────────────────────────────────
+
+function makeWidget(overrides: Partial = {}): DashboardWidget {
+ return {
+ id: "w-1",
+ chartType: "bar",
+ connectionId: "conn-1",
+ query: "MATCH (n) RETURN n",
+ settings: { title: "Test Widget" },
+ ...overrides,
+ };
+}
+
+function makePage(widgets: DashboardWidget[] = [makeWidget()]): DashboardPage {
+ return {
+ id: "page-1",
+ title: "Test Page",
+ widgets,
+ gridLayout: widgets.map((w, i) => ({
+ i: w.id,
+ x: 0,
+ y: i * 2,
+ w: 12,
+ h: 2,
+ })),
+ };
+}
+
+function renderWithProviders(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+ {ui} ,
+ );
+}
+
+describe("DashboardContainer — double-click to edit", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("calls onEditWidget with the widget when double-clicking in edit mode", async () => {
+ const user = userEvent.setup();
+ const onEditWidget = vi.fn();
+ const widget = makeWidget();
+
+ renderWithProviders(
+ ,
+ );
+
+ const widgetDiv = screen.getByTestId("widget-card");
+ await user.dblClick(widgetDiv);
+
+ expect(onEditWidget).toHaveBeenCalledTimes(1);
+ expect(onEditWidget).toHaveBeenCalledWith(widget);
+ });
+
+ it("does NOT call onEditWidget on double-click when editable is false", async () => {
+ const user = userEvent.setup();
+ const onEditWidget = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const widgetDiv = screen.getByTestId("widget-card");
+ await user.dblClick(widgetDiv);
+
+ expect(onEditWidget).not.toHaveBeenCalled();
+ });
+
+ it("does NOT call onEditWidget on double-click when onEditWidget is not provided", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ const widgetDiv = screen.getByTestId("widget-card");
+ // Should not throw — onDoubleClick is undefined so nothing happens
+ await user.dblClick(widgetDiv);
+ // No error means the handler was properly set to undefined
+ });
+
+ it("shows empty state when page has no widgets", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("No widgets to display")).toBeInTheDocument();
+ });
+
+ it("calls onEditWidget with correct widget in multi-widget page", async () => {
+ const user = userEvent.setup();
+ const onEditWidget = vi.fn();
+ const widget1 = makeWidget({ id: "w-1", settings: { title: "Widget 1" } });
+ const widget2 = makeWidget({ id: "w-2", settings: { title: "Widget 2" } });
+
+ renderWithProviders(
+ ,
+ );
+
+ const widgetDivs = screen.getAllByTestId("widget-card");
+ expect(widgetDivs).toHaveLength(2);
+
+ // Double-click the second widget
+ await user.dblClick(widgetDivs[1]);
+
+ expect(onEditWidget).toHaveBeenCalledTimes(1);
+ expect(onEditWidget).toHaveBeenCalledWith(widget2);
+ });
+});
diff --git a/app/src/components/__tests__/dashboard-error-boundary.test.tsx b/app/src/components/__tests__/dashboard-error-boundary.test.tsx
new file mode 100644
index 000000000..7511bbae6
--- /dev/null
+++ b/app/src/components/__tests__/dashboard-error-boundary.test.tsx
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { DashboardErrorBoundary } from "../dashboard-error-boundary";
+
+function ThrowingChild({ shouldThrow }: { shouldThrow: boolean }) {
+ if (shouldThrow) throw new Error("Boom");
+ return OK
;
+}
+
+beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+});
+
+describe("DashboardErrorBoundary", () => {
+ it("renders children when no error", () => {
+ render(
+
+ Dashboard content
+ ,
+ );
+ expect(screen.getByText("Dashboard content")).toBeInTheDocument();
+ });
+
+ it("shows fallback UI when a child throws", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /try again/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("recovers when 'Try again' is clicked", () => {
+ const { rerender } = render(
+
+
+ ,
+ );
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+
+ // Rerender with non-throwing child, then click retry
+ rerender(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: /try again/i }));
+ expect(screen.getByText("OK")).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/__tests__/form-widget-renderer-fields.test.tsx b/app/src/components/__tests__/form-widget-renderer-fields.test.tsx
new file mode 100644
index 000000000..258b20418
--- /dev/null
+++ b/app/src/components/__tests__/form-widget-renderer-fields.test.tsx
@@ -0,0 +1,554 @@
+/**
+ * FormWidgetRenderer — FieldInput branch coverage per parameterType.
+ *
+ * Covers: text, select (static + seed), multi-select, date, date-range,
+ * date-relative, number-range, cascading-select, and the default (unknown
+ * parameterType) fall-through. Also covers submit flow success/error,
+ * empty-fields fast path, and submit-button states.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import React from "react";
+import type { FormFieldDef } from "@/lib/widget/form-field-def";
+
+/* ---------- mocks (declared before imports) ---------- */
+
+const mockUseSession = vi.fn();
+vi.mock("next-auth/react", () => ({
+ useSession: (...args: unknown[]) => mockUseSession(...args),
+}));
+
+// Capture props passed to each component-library widget so assertions
+// can inspect what the renderer handed them.
+const paramSelectorProps: Array> = [];
+const paramMultiProps: Array> = [];
+const datePickerProps: Array> = [];
+const dateRangeProps: Array> = [];
+const dateRelativeProps: Array> = [];
+const numberRangeProps: Array> = [];
+const cascadingProps: Array> = [];
+
+vi.mock("@neoboard/components", () => ({
+ ParamSelector: (p: Record) => {
+ paramSelectorProps.push(p);
+ return (
+ (p.onChange as (v: string) => void)("ok")}
+ >
+ ParamSelector
+
+ );
+ },
+ ParamMultiSelector: (p: Record) => {
+ paramMultiProps.push(p);
+ return (
+ (p.onChange as (v: string[]) => void)(["a", "b"])}
+ >
+ ParamMultiSelector
+
+ );
+ },
+ DatePickerParameter: (p: Record) => {
+ datePickerProps.push(p);
+ return (
+ (p.onChange as (v: string) => void)("2026-01-01")}
+ >
+ DatePicker
+
+ );
+ },
+ DateRangeParameter: (p: Record) => {
+ dateRangeProps.push(p);
+ return (
+
+ (p.onChange as (f: string, t: string) => void)(
+ "2026-01-01",
+ "2026-01-31",
+ )
+ }
+ >
+ DateRange
+
+ );
+ },
+ DateRelativePicker: (p: Record) => {
+ dateRelativeProps.push(p);
+ return (
+ (p.onChange as (v: string) => void)("last_7_days")}
+ >
+ DateRelative
+
+ );
+ },
+ NumberRangeSlider: (p: Record) => {
+ numberRangeProps.push(p);
+ return (
+
+
+ (p.onChange as (v: [number, number]) => void)([10, 20])
+ }
+ >
+ set
+
+
+ );
+ },
+ CascadingSelector: (p: Record) => {
+ cascadingProps.push(p);
+ return (
+ (p.onChange as (v: string) => void)("child")}
+ >
+ Cascading
+
+ );
+ },
+ Button: ({
+ children,
+ ...rest
+ }: React.ButtonHTMLAttributes) => (
+