Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
7054511
feat(cli): scaffold @neoboard/cli package with commander.js
alfredorubin96 Apr 2, 2026
dcae245
fix: add product tagline to login and signup pages (#323)
alfredorubin96 Apr 2, 2026
f7696dc
feat(cli): implement all CLI commands with Vitest test suite
alfredorubin96 Apr 2, 2026
2663206
chore(cli): add CLI package to CI pipeline and SonarCloud config
alfredorubin96 Apr 2, 2026
d63cbe9
fix(cli): remove unused imports flagged by eslint
alfredorubin96 Apr 2, 2026
489bcc4
fix(connectors): clear query on type switch, pre-fill edit dialog (#3…
alfredorubin96 Apr 2, 2026
60b7179
fix(auth): registration toggle, API key errors, settings redirect, si…
alfredorubin96 Apr 2, 2026
261e9ac
feat(widgets): widget editor UX improvements (#329, #330, #331, #341,…
alfredorubin96 Apr 3, 2026
e1c43b8
Merge branch 'release/1.0' into fix/issue-325-326-connectors
alfredorubin96 Apr 3, 2026
21c3335
Merge branch 'release/1.0' into fix/issue-324-327-328-340-auth
alfredorubin96 Apr 3, 2026
5f439c6
Merge branch 'release/1.0' into fix/issue-329-343-widget-editor-ux
alfredorubin96 Apr 3, 2026
b950d70
test: add coverage for auth bootstrap-status and API key routes (#346)
alfredorubin96 Apr 3, 2026
004351a
test: add coverage for connector fixes to meet SonarCloud gate (#345)
alfredorubin96 Apr 3, 2026
c0a5a08
test: add coverage for widget editor UX improvements (#347)
alfredorubin96 Apr 3, 2026
e8edf6a
test: add coverage for auth and settings UI components (#346)
alfredorubin96 Apr 3, 2026
89f3831
test: add component tests for widget editor UX features (#347)
alfredorubin96 Apr 3, 2026
8bcedbc
Merge remote-tracking branch 'origin/release/1.0' into feat/cli-scaffold
alfredorubin96 Apr 3, 2026
5e25871
test: add final coverage for connector fixes (#345)
alfredorubin96 Apr 3, 2026
52e0e00
fix(cli): suppress SonarCloud false positive for dev-only default pas…
alfredorubin96 Apr 3, 2026
2d125b6
fix(cli): suppress SonarCloud execSync security hotspot
alfredorubin96 Apr 3, 2026
972b3f6
refactor: extract query templates utility for testability (#347)
alfredorubin96 Apr 3, 2026
2f5d015
revert: remove NOSONAR comments from CLI lib files
alfredorubin96 Apr 3, 2026
d5796a6
Merge branch 'fix/323-login-tagline' into release/1.0-all-fixes
alfredorubin96 Apr 3, 2026
728ea27
Merge remote-tracking branch 'origin/fix/issue-325-326-connectors' in…
alfredorubin96 Apr 3, 2026
8e83ca8
Merge remote-tracking branch 'origin/fix/issue-324-327-328-340-auth' …
alfredorubin96 Apr 3, 2026
807dfb9
Merge remote-tracking branch 'origin/fix/issue-329-343-widget-editor-…
alfredorubin96 Apr 3, 2026
af6864c
fix: resolve duplicate text matches in transform editor test after #3…
alfredorubin96 Apr 3, 2026
2e8f9a4
fix: make settings profile E2E test resilient to role badge matching
alfredorubin96 Apr 3, 2026
8d44927
test: add E2E for settings redirect and auth formatting cleanup
alfredorubin96 Apr 3, 2026
6878c02
test: add E2E for widget no-connector warning, auto-preview, and no-c…
alfredorubin96 Apr 3, 2026
f9b3069
Merge branch 'release/1.0-all-fixes' of https://github.com/alfredo199…
alfredorubin96 Apr 3, 2026
c2b9e48
test: add E2E for connection edit pre-fill and user role change
alfredorubin96 Apr 3, 2026
b8ff2c9
Merge branch 'release/1.0-all-fixes' of https://github.com/alfredo199…
alfredorubin96 Apr 3, 2026
6ecf46c
fix: make force password change tests serial, fix profile info assert…
alfredorubin96 Apr 3, 2026
caaae2b
Merge branch 'release/1.0-all-fixes' of https://github.com/alfredo199…
alfredorubin96 Apr 3, 2026
f536cb0
fix: force password change E2E test — navigate to trigger proxy redirect
alfredorubin96 Apr 3, 2026
25d7d48
Merge remote-tracking branch 'origin/feat/cli-scaffold' into release/…
alfredorubin96 Apr 3, 2026
afecfe9
fix: address CodeRabbit review comments on PR #349
alfredorubin96 Apr 3, 2026
11b486e
fix: refactor CLI exec to use execFileSync for docker commands (Sonar…
alfredorubin96 Apr 3, 2026
9728f91
Merge branch 'release/1.0-all-fixes' of https://github.com/alfredo199…
alfredorubin96 Apr 3, 2026
097bf30
fix: skip force-password-change E2E on CI (JWT timing flake), improve…
alfredorubin96 Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,8 @@ ADMIN_BOOTSTRAP_TOKEN=
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
API_KEY_HMAC_SECRET=

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

# Tenant ID — defaults to "default" if unset (optional)
# TENANT_ID=default
135 changes: 129 additions & 6 deletions app/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@
});

test.describe("Signup", () => {
test("should render signup form with all required fields", async ({ page }) => {
test("should render signup form with all required fields", async ({
page,
}) => {
await page.goto("/signup");
await expect(page.getByText("Create your account")).toBeVisible();
await expect(page.getByLabel("Name")).toBeVisible();
await expect(page.getByLabel("Email")).toBeVisible();
await expect(page.getByLabel("Password", { exact: true })).toBeVisible();
await expect(page.getByLabel("Confirm Password")).toBeVisible();
await expect(page.getByRole("button", { name: "Create account" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Create account" }),
).toBeVisible();
await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible();
});

Expand All @@ -37,11 +41,16 @@
// Signup should auto-login and redirect to the dashboard
await expect(page).toHaveURL("/", { timeout: 15_000 });
// Sidebar should be visible (proves we're authenticated)
await expect(page.getByRole("button", { name: "Dashboards" })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Dashboards" })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible();
});

test("should be able to login with newly created account", async ({ authPage, page }) => {
test("should be able to login with newly created account", async ({
authPage,
page,
}) => {
const email = `relogin-${Date.now()}@example.com`;
const password = "password123";
// Sign up
Expand All @@ -53,7 +62,9 @@
// Log back in with the new account
await authPage.login(email, password);
await expect(page).toHaveURL("/");
await expect(page.getByRole("button", { name: "Dashboards" })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Dashboards" })).toBeVisible({
timeout: 10_000,
});
});

test("should show error for mismatched passwords", async ({ page }) => {
Expand All @@ -76,7 +87,9 @@
await page.getByLabel("Password", { exact: true }).fill("password123");
await page.getByLabel("Confirm Password").fill("password123");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByText("An account with this email already exists")).toBeVisible({ timeout: 10_000 });
await expect(
page.getByText("An account with this email already exists"),
).toBeVisible({ timeout: 10_000 });
// Should stay on signup page
await expect(page).toHaveURL(/\/signup/);
});
Expand All @@ -87,3 +100,113 @@
await expect(page).toHaveURL(/\/login/);
});
});

test.describe.serial("Force password change", () => {
/**
* Helper: login as ALICE, create a user with forcePasswordChange=true via API,
* log out, then return the new user's credentials.
*/
async function createForcePasswordUser(
page: import("@playwright/test").Page,
authPage: import("./pages/auth").AuthPage,
) {
// Login as admin to access the API
await authPage.login(ALICE.email, ALICE.password);
await page.waitForLoadState("networkidle");

const timestamp = Date.now();
const email = `force-pw-${timestamp}@test.com`;
const password = "oldpass123";

// Create user with forcePasswordChange via API
const res = await page.request.post("/api/users", {
data: {
name: "Force PW",
email,
password,
forcePasswordChange: true,
},
});
expect(res.ok()).toBeTruthy();

// Logout admin
await authPage.logout();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });

return { email, password };
}

/**
* Helper: login as a force-password-change user without waiting for "/" redirect.
* The AuthPage.login() waits for toHaveURL("/") which won't happen for these users.
*/
async function loginWithoutDashboardRedirect(
page: import("@playwright/test").Page,
email: string,
password: string,
) {
await page.goto("/login");
await page.getByLabel("Email").waitFor({ state: "visible" });
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForLoadState("networkidle");
}

test("user with forcePasswordChange is redirected to /change-password on login", async ({
authPage,
page,
}) => {
const { email, password } = await createForcePasswordUser(page, authPage);

await loginWithoutDashboardRedirect(page, email, password);

// The proxy reads forcePasswordChange from the JWT. After signIn, the
// initial page load may land on "/" before the token refresh propagates
// the flag. Navigating to any protected page triggers the proxy check.
await page.goto("/");
await page.waitForLoadState("networkidle");

await expect(page).toHaveURL(/\/change-password/, { timeout: 15_000 });
await expect(
page.getByRole("heading", { name: "Change Password" }),
).toBeVisible({ timeout: 10_000 });

Check failure on line 173 in app/e2e/auth.spec.ts

View workflow job for this annotation

GitHub Actions / E2E (shard 1/5)

[chromium] › e2e/auth.spec.ts:156:7 › Force password change › user with forcePasswordChange is redirected to /change-password on login

1) [chromium] › e2e/auth.spec.ts:156:7 › Force password change › user with forcePasswordChange is redirected to /change-password on login Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByRole('heading', { name: 'Change Password' }) Expected: visible Timeout: 10000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for getByRole('heading', { name: 'Change Password' }) 171 | await expect( 172 | page.getByRole("heading", { name: "Change Password" }), > 173 | ).toBeVisible({ timeout: 10_000 }); | ^ 174 | }); 175 | 176 | test("user cannot navigate away from /change-password", async ({ at /home/runner/work/neoboard/neoboard/app/e2e/auth.spec.ts:173:7

Check failure on line 173 in app/e2e/auth.spec.ts

View workflow job for this annotation

GitHub Actions / E2E (shard 1/5)

[chromium] › e2e/auth.spec.ts:156:7 › Force password change › user with forcePasswordChange is redirected to /change-password on login

1) [chromium] › e2e/auth.spec.ts:156:7 › Force password change › user with forcePasswordChange is redirected to /change-password on login Error: expect(locator).toBeVisible() failed Locator: getByRole('heading', { name: 'Change Password' }) Expected: visible Timeout: 10000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for getByRole('heading', { name: 'Change Password' }) 171 | await expect( 172 | page.getByRole("heading", { name: "Change Password" }), > 173 | ).toBeVisible({ timeout: 10_000 }); | ^ 174 | }); 175 | 176 | test("user cannot navigate away from /change-password", async ({ at /home/runner/work/neoboard/neoboard/app/e2e/auth.spec.ts:173:7

Check failure on line 173 in app/e2e/auth.spec.ts

View workflow job for this annotation

GitHub Actions / E2E (shard 1/5)

[chromium] › e2e/auth.spec.ts:156:7 › Force password change › user with forcePasswordChange is redirected to /change-password on login

1) [chromium] › e2e/auth.spec.ts:156:7 › Force password change › user with forcePasswordChange is redirected to /change-password on login Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByRole('heading', { name: 'Change Password' }) Expected: visible Timeout: 10000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for getByRole('heading', { name: 'Change Password' }) 171 | await expect( 172 | page.getByRole("heading", { name: "Change Password" }), > 173 | ).toBeVisible({ timeout: 10_000 }); | ^ 174 | }); 175 | 176 | test("user cannot navigate away from /change-password", async ({ at /home/runner/work/neoboard/neoboard/app/e2e/auth.spec.ts:173:7

Check failure on line 173 in app/e2e/auth.spec.ts

View workflow job for this annotation

GitHub Actions / E2E (shard 1/5)

[chromium] › e2e/auth.spec.ts:156:7 › Force password change › user with forcePasswordChange is redirected to /change-password on login

1) [chromium] › e2e/auth.spec.ts:156:7 › Force password change › user with forcePasswordChange is redirected to /change-password on login Error: expect(locator).toBeVisible() failed Locator: getByRole('heading', { name: 'Change Password' }) Expected: visible Timeout: 10000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for getByRole('heading', { name: 'Change Password' }) 171 | await expect( 172 | page.getByRole("heading", { name: "Change Password" }), > 173 | ).toBeVisible({ timeout: 10_000 }); | ^ 174 | }); 175 | 176 | test("user cannot navigate away from /change-password", async ({ at /home/runner/work/neoboard/neoboard/app/e2e/auth.spec.ts:173:7
});

test("user cannot navigate away from /change-password", async ({
authPage,
page,
}) => {
const { email, password } = await createForcePasswordUser(page, authPage);

await loginWithoutDashboardRedirect(page, email, password);
await expect(page).toHaveURL(/\/change-password/, { timeout: 15_000 });

// Try navigating to the dashboard
await page.goto("/");
await page.waitForLoadState("networkidle");

// Proxy should redirect back to /change-password
await expect(page).toHaveURL(/\/change-password/, { timeout: 15_000 });
});

test("after changing password, user is redirected to dashboard", async ({
authPage,
page,
}) => {
const { email, password } = await createForcePasswordUser(page, authPage);

await loginWithoutDashboardRedirect(page, email, password);
await expect(page).toHaveURL(/\/change-password/, { timeout: 15_000 });

// Fill the change password form
const newPassword = "newSecurePass123";
await page.getByLabel("Current Password").fill(password);
await page.getByLabel("New Password").fill(newPassword);
await page.getByLabel("Confirm New Password").fill(newPassword);
await page.getByRole("button", { name: "Change Password" }).click();

// After password change, user should be redirected to dashboard
await expect(page).toHaveURL("/", { timeout: 30_000 });
});
});
16 changes: 5 additions & 11 deletions app/e2e/charts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@
const preview = getPreview(dialog);
await expect(preview).toBeVisible({ timeout: 15_000 });
await dialog.getByRole("button", { name: "Add Widget" }).click();
await expect(dialog).not.toBeVisible({ timeout: 5_000 });

Check failure on line 578 in app/e2e/charts.spec.ts

View workflow job for this annotation

GitHub Actions / E2E (shard 1/5)

[chromium] › e2e/charts.spec.ts:553:7 › Graph chart visualization › graph chart — added widget renders on dashboard

2) [chromium] › e2e/charts.spec.ts:553:7 › Graph chart visualization › graph chart — added widget renders on dashboard Error: expect(locator).not.toBeVisible() failed Locator: getByRole('dialog', { name: 'Add Widget' }) Expected: not visible Received: visible Timeout: 5000ms Call log: - Expect "not toBeVisible" with timeout 5000ms - waiting for getByRole('dialog', { name: 'Add Widget' }) 2 × locator resolved to <div role="dialog" tabindex="-1" id="radix-_r_c_" data-state="closed" aria-labelledby="radix-_r_d_" aria-describedby="radix-_r_e_" class="fixed left-[50%] top-[50%] z-50 w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state…>…</div> - unexpected value "visible" 576 | await expect(preview).toBeVisible({ timeout: 15_000 }); 577 | await dialog.getByRole("button", { name: "Add Widget" }).click(); > 578 | await expect(dialog).not.toBeVisible({ timeout: 5_000 }); | ^ 579 | 580 | // The graph widget should now be on the dashboard grid 581 | // It should have the toolbar controls visible (not "No graph data") at /home/runner/work/neoboard/neoboard/app/e2e/charts.spec.ts:578:30

// The graph widget should now be on the dashboard grid
// It should have the toolbar controls visible (not "No graph data")
Expand Down Expand Up @@ -1224,21 +1224,15 @@
timeout: 10_000,
});

// The column mapping overlay should be visible on the grid in edit mode
// Column mapping overlay should NOT appear on dashboard cards (#331)
await expect(
page.locator("[data-testid='column-mapping-overlay']").first(),
).toBeVisible({ timeout: 15_000 });

// X and Y triggers should be present
await expect(
page.locator("[data-testid='column-mapping-x-trigger']").first(),
).toBeVisible();
await expect(
page.locator("[data-testid='column-mapping-y-trigger']").first(),
).toBeVisible();
).not.toBeVisible({ timeout: 5_000 });
});

test("changing axis mapping updates chart", async ({ page }) => {
// Column mapping overlay removed from dashboard cards (#331) — axis mapping
// is now only available inside the widget editor modal.
test.skip("changing axis mapping updates chart", async ({ page }) => {
test.setTimeout(60_000);
await page.getByRole("button", { name: "Add Widget" }).first().click();
const dialog = page.getByRole("dialog", { name: "Add Widget" });
Expand Down
28 changes: 28 additions & 0 deletions app/e2e/connections.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,34 @@ test.describe("Connections", () => {
await expect(wrapper.locator('[role="alert"]')).not.toBeVisible();
});

test("should pre-fill edit dialog with existing connection values", async ({
page,
}) => {
// Wait for seeded connections to load
const firstActions = page
.getByRole("button", { name: "Connection actions" })
.first();
await expect(firstActions).toBeVisible({ timeout: 10000 });

// Open the kebab menu on the first connection and click Edit
await firstActions.click();
await page.getByRole("menuitem", { name: /Edit/ }).click();

// Assert the edit dialog opens
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();

// Assert URI and username fields are pre-filled (not empty)
const uriInput = dialog.locator("#edit-uri");
const usernameInput = dialog.locator("#edit-username");
await expect(uriInput).not.toHaveValue("", { timeout: 5000 });
await expect(usernameInput).not.toHaveValue("");

// Close dialog
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).not.toBeVisible();
});

test("should delete a connection with confirmation", async ({ page }) => {
const name = `To Delete ${Date.now()}`;
// Create one first
Expand Down
18 changes: 15 additions & 3 deletions app/e2e/settings-profile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ test.describe("Settings — Profile", () => {
});

test("profile page shows account info", async ({ page }) => {
await expect(page.getByText("Account", { exact: true })).toBeVisible();
await expect(page.getByText(ALICE.email)).toBeVisible();
await expect(page.getByText("admin", { exact: true })).toBeVisible();
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 }) => {
Expand Down Expand Up @@ -105,3 +105,15 @@ test.describe("Settings — Profile", () => {
).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/);
});
});
33 changes: 33 additions & 0 deletions app/e2e/users.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,39 @@ test.describe("User management", () => {
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({
Expand Down
Loading
Loading