mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb05f2c172 | ||
|
|
6cf828ce8a | ||
|
|
83cb07a44c | ||
|
|
bbfa2d3a79 | ||
|
|
a92816b9b8 | ||
|
|
0caf754897 | ||
|
|
49582fff74 | ||
|
|
af7745a03b | ||
|
|
19d82cc2ab | ||
|
|
b66cc4ccb7 | ||
|
|
1d408f2e7e | ||
|
|
9fab9d7a83 |
No files matched your search
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals","prettier"],
|
||||
"plugins": ["simple-import-sort"],
|
||||
"rules": {
|
||||
"simple-import-sort/imports": [
|
||||
"warn",
|
||||
{
|
||||
"groups": [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]]
|
||||
}
|
||||
],
|
||||
"simple-import-sort/exports": "warn"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.14
|
||||
FROM alpine:3.24
|
||||
|
||||
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
|
||||
nginx curl supervisor certbot-nginx && \
|
||||
|
||||
@@ -483,3 +483,28 @@ export async function deleteAgentNetworkProvidersByPrefix(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the management build under test bootstraps the Agent Network
|
||||
* settings row through the explicit POST /agent-network/settings.
|
||||
*
|
||||
* Builds that predate it seed the row as a side effect of the first provider
|
||||
* create and describe the endpoint identity as cluster + subdomain; builds
|
||||
* that have it expose proxy_address instead. The settings GET carries the
|
||||
* field either way — including before the account is bootstrapped, where it
|
||||
* answers with the defaults and an empty proxy_address — so the shape of that
|
||||
* response is a side-effect-free probe for the endpoint's existence.
|
||||
*/
|
||||
export async function supportsAgentNetworkSettingsBootstrap(
|
||||
page: Page,
|
||||
): Promise<boolean> {
|
||||
const { token, origin } = await getApiContext(page);
|
||||
const resp = await page.request.get(`${origin}/api/agent-network/settings`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!resp.ok()) return false;
|
||||
const settings = await resp.json().catch(() => null);
|
||||
return (
|
||||
!!settings && typeof settings === "object" && "proxy_address" in settings
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
* The kimi_api catalog entry ships with newer management builds. When the
|
||||
* backend under test predates it, the whole suite skips instead of failing —
|
||||
* same trade-off as the provider matrix in netbird's agent-network e2e
|
||||
* harness, which skips per-provider on missing credentials.
|
||||
* harness, which skips per-provider on missing credentials. The same applies
|
||||
* to the explicit settings bootstrap: connecting the first provider of an
|
||||
* account POSTs /agent-network/settings first, so on a build without that
|
||||
* endpoint the wizard cannot get as far as creating a provider.
|
||||
*
|
||||
* The Agent Network menu is deployment-gated; the test build honors the
|
||||
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
|
||||
@@ -22,6 +25,7 @@ import { generateRandomName } from "../helpers/utils";
|
||||
import {
|
||||
deleteAgentNetworkProvidersByPrefix,
|
||||
listAgentNetworkCatalog,
|
||||
supportsAgentNetworkSettingsBootstrap,
|
||||
} from "../helpers/api";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
@@ -62,6 +66,11 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
|
||||
!catalog.some((c) => c.id === KIMI_CATALOG_ID),
|
||||
`management catalog has no ${KIMI_CATALOG_ID} entry`,
|
||||
);
|
||||
test.skip(
|
||||
!(await supportsAgentNetworkSettingsBootstrap(page)),
|
||||
"management build has no POST /agent-network/settings, so the " +
|
||||
"wizard cannot bootstrap the account before the first create",
|
||||
);
|
||||
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
|
||||
@@ -110,11 +119,40 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
// On the account's first provider the wizard bootstraps the settings row
|
||||
// first and only creates the provider once that succeeds. A rejected
|
||||
// bootstrap therefore means no provider POST ever happens, which on its
|
||||
// own surfaces only as the whole test timing out — race it so the real
|
||||
// status is reported instead. 409 is not a rejection: it means a
|
||||
// concurrent bootstrap won and the row exists, which the wizard treats as
|
||||
// success and follows with the provider create, so matching it here would
|
||||
// fail a run that is about to succeed.
|
||||
const bootstrapRejected: Promise<never> = page
|
||||
.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/agent-network/settings") &&
|
||||
resp.request().method() === "POST" &&
|
||||
resp.status() !== 409 &&
|
||||
!resp.ok(),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.then(
|
||||
(resp) => {
|
||||
throw new Error(
|
||||
`Agent Network settings bootstrap POST returned ${resp.status()}; ` +
|
||||
"the provider was never created",
|
||||
);
|
||||
},
|
||||
// Nothing matched: the bootstrap succeeded or wasn't needed. Never
|
||||
// settle, leaving the race to the provider create.
|
||||
() => new Promise<never>(() => {}),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.last()
|
||||
.click({ force: true });
|
||||
expect([200, 201]).toContain((await createResponse).status());
|
||||
const created = await Promise.race([createResponse, bootstrapRejected]);
|
||||
expect([200, 201]).toContain(created.status());
|
||||
|
||||
// Row lands in the providers table.
|
||||
await expect(page.getByText(providerName).first()).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Agent Network settings wire-shape spec.
|
||||
*
|
||||
* The management API signals "account not bootstrapped" differently by
|
||||
* generation: current servers answer GET /agent-network/settings with the
|
||||
* defaults object carrying an empty endpoint/proxy_address, older ones with
|
||||
* 200 + a JSON null body, and the oldest with a 404.
|
||||
* useAgentNetworkSettings normalizes all three to the same null-settings
|
||||
* signal, and this spec pins that: the providers page must render the
|
||||
* connect-first endpoint placeholder for every unbootstrapped shape, and the
|
||||
* endpoint badge once the account is bootstrapped. The settings route is
|
||||
* mocked per test, so the assertions do not depend on the backend build or
|
||||
* on account state left behind by other suites.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
const EMPTY_STATE_TEXT =
|
||||
"Connect your first provider to set up your agent network endpoint.";
|
||||
|
||||
const BOOTSTRAPPED_ENDPOINT = "violet.eu.proxy.netbird.io";
|
||||
|
||||
// The defaults object current servers return for an account that has not been
|
||||
// bootstrapped yet: the mutable settings carry their default values, the
|
||||
// endpoint/proxy_address identity is empty, and there are no timestamps because
|
||||
// no row has been persisted.
|
||||
const UNBOOTSTRAPPED_DEFAULTS = {
|
||||
endpoint: "",
|
||||
proxy_address: "",
|
||||
dedicated: false,
|
||||
enable_log_collection: true,
|
||||
enable_prompt_collection: false,
|
||||
redact_pii: false,
|
||||
access_log_retention_days: 30,
|
||||
};
|
||||
|
||||
const BOOTSTRAPPED_SETTINGS = {
|
||||
endpoint: BOOTSTRAPPED_ENDPOINT,
|
||||
proxy_address: "eu.proxy.netbird.io",
|
||||
dedicated: false,
|
||||
enable_log_collection: true,
|
||||
enable_prompt_collection: false,
|
||||
redact_pii: false,
|
||||
access_log_retention_days: 30,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
async function mockSettingsResponse(
|
||||
page: Page,
|
||||
response: { status: number; body: string },
|
||||
) {
|
||||
await page.route("**/api/agent-network/settings", (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
return route.fulfill({
|
||||
status: response.status,
|
||||
contentType: "application/json",
|
||||
body: response.body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openProvidersPage(
|
||||
browser: Browser,
|
||||
settingsResponse: { status: number; body: string },
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
// Close the context on any setup failure — the caller only receives the
|
||||
// close callback once setup succeeds.
|
||||
try {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
await mockSettingsResponse(page, settingsResponse);
|
||||
await loginToApp(page, "owner");
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
return { page, close: () => context.close() };
|
||||
} catch (e) {
|
||||
await context.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const UNBOOTSTRAPPED_SHAPES: {
|
||||
name: string;
|
||||
response: { status: number; body: string };
|
||||
}[] = [
|
||||
{
|
||||
name: "defaults object with empty endpoint (current servers)",
|
||||
response: { status: 200, body: JSON.stringify(UNBOOTSTRAPPED_DEFAULTS) },
|
||||
},
|
||||
{
|
||||
name: "200 with JSON null body (older servers)",
|
||||
response: { status: 200, body: "null" },
|
||||
},
|
||||
{
|
||||
name: "404 (oldest servers)",
|
||||
response: {
|
||||
status: 404,
|
||||
body: JSON.stringify({ message: "settings not found", code: 404 }),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Agent Network settings wire shapes @agent-network", () => {
|
||||
for (const shape of UNBOOTSTRAPPED_SHAPES) {
|
||||
test(`unbootstrapped account renders the empty state: ${shape.name}`, async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openProvidersPage(browser, shape.response);
|
||||
try {
|
||||
await expect(page.getByText(EMPTY_STATE_TEXT)).toBeVisible();
|
||||
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("bootstrapped account renders the endpoint instead of the empty state", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openProvidersPage(browser, {
|
||||
status: 200,
|
||||
body: JSON.stringify(BOOTSTRAPPED_SETTINGS),
|
||||
});
|
||||
try {
|
||||
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toBeVisible();
|
||||
await expect(page.getByText(EMPTY_STATE_TEXT)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -72,13 +72,19 @@ function mockAccounts(page: Page, state: AccountState, delayMs: number) {
|
||||
|
||||
async function openOnboarding(
|
||||
browser: Browser,
|
||||
opts: { source?: boolean; account: AccountState; delayMs?: number },
|
||||
opts: {
|
||||
source?: boolean;
|
||||
account: AccountState;
|
||||
delayMs?: number;
|
||||
edition?: string;
|
||||
expectOnboarding?: boolean;
|
||||
},
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
await context.addInitScript(
|
||||
([sourceKey, sourceValue, withSource]) => {
|
||||
([sourceKey, sourceValue, withSource, edition]) => {
|
||||
try {
|
||||
window.localStorage.setItem("netbird-test-onboarding", "true");
|
||||
if (withSource) {
|
||||
@@ -87,13 +93,32 @@ async function openOnboarding(
|
||||
sourceValue as string,
|
||||
);
|
||||
}
|
||||
if (edition) {
|
||||
window.localStorage.setItem("netbird-test-edition", edition as string);
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE, !!opts.source] as const,
|
||||
[
|
||||
SIGNUP_SOURCE_KEY,
|
||||
AGENT_NETWORK_SOURCE,
|
||||
!!opts.source,
|
||||
opts.edition ?? "",
|
||||
] as const,
|
||||
);
|
||||
const page = await context.newPage();
|
||||
mockAccounts(page, opts.account, opts.delayMs ?? 0);
|
||||
await loginToApp(page, "owner", { expectOnboarding: true });
|
||||
// The mocked GET /accounts is what drives the onboarding decision, and the
|
||||
// dashboard can resolve before it lands — a "no form opened" assertion would
|
||||
// then pass vacuously. Wait for the rewritten response before returning.
|
||||
const accountsLoaded = page.waitForResponse(
|
||||
(resp) =>
|
||||
/\/api\/accounts(\?|$)/.test(resp.url()) &&
|
||||
resp.request().method() === "GET",
|
||||
);
|
||||
await loginToApp(page, "owner", {
|
||||
expectOnboarding: opts.expectOnboarding ?? true,
|
||||
});
|
||||
await accountsLoaded;
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
@@ -143,6 +168,50 @@ test.describe.serial("Onboarding form selection @onboarding", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a self-hosted account with onboarding pending shows the regular flow at the intent step", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOnboarding(browser, {
|
||||
source: false,
|
||||
edition: "oss",
|
||||
account: { onboardingFlowPending: true },
|
||||
});
|
||||
try {
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toBeVisible();
|
||||
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
|
||||
// The signup survey relies on a JWT domain claim self-hosted IdPs don't
|
||||
// emit, so the flow skips it and opens on the intent step. Scoped to the
|
||||
// form: the dashboard behind the modal has its own "Get Started with
|
||||
// NetBird" heading, and getByText matches case-insensitively.
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(REGULAR_FORM)
|
||||
.getByRole("heading", { name: "Get started with NetBird" }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a self-hosted account with only the signup form pending shows no onboarding", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOnboarding(browser, {
|
||||
source: false,
|
||||
edition: "oss",
|
||||
account: { signupFormPending: true },
|
||||
expectOnboarding: false,
|
||||
});
|
||||
try {
|
||||
// loginToApp resolved the dashboard, so the account state has been
|
||||
// applied — neither flow should have opened.
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
|
||||
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a slow backend never flashes the regular form for a netbird.ai signup", async ({
|
||||
browser,
|
||||
}) => {
|
||||
|
||||
@@ -128,6 +128,14 @@ async function openRowActions(
|
||||
name: string,
|
||||
) {
|
||||
await clearScrollLock(page);
|
||||
// A force-click can open a new action menu without the previous one having
|
||||
// closed (scroll-lock artifacts suppress Radix's outside-click dismissal),
|
||||
// leaving two menus open — the item lookup then hits a strict-mode violation.
|
||||
// Dismiss any open menu and wait for it to be gone before opening the next.
|
||||
if (await page.locator('[role="menu"]').count()) {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator('[role="menu"]')).toHaveCount(0);
|
||||
}
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: name })
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Linux tab of the setup modal — distro selector spec.
|
||||
*
|
||||
* The manual-install accordion offers per-distro instructions via a
|
||||
* SelectDropdown (data-testid="linux-distro-select"). This validates that
|
||||
* switching distros swaps the repository and install commands, that
|
||||
* CLI-only distros render no desktop-app line, and that the copy button
|
||||
* emits commands without comment lines. Runs against /install, which
|
||||
* renders the setup modal without authentication.
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
async function openLinuxManualInstall(page: import("@playwright/test").Page) {
|
||||
await page.goto("/install");
|
||||
await page.getByTestId("setup-netbird-modal").waitFor();
|
||||
await page.getByRole("tab", { name: /linux/i }).click();
|
||||
await page.getByText("Install manually with a package manager").click();
|
||||
await page.getByTestId("linux-distro-select").waitFor();
|
||||
}
|
||||
|
||||
async function selectDistro(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
) {
|
||||
await page.getByTestId("linux-distro-select").click();
|
||||
await page.getByRole("option", { name }).click();
|
||||
}
|
||||
|
||||
test.describe("Setup modal Linux distro instructions", () => {
|
||||
test("defaults to APT with repository, install and run steps", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openLinuxManualInstall(page);
|
||||
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
await expect(modal.getByTestId("linux-distro-select")).toContainText(
|
||||
"Debian / Ubuntu (APT)",
|
||||
);
|
||||
await expect(modal).toContainText(
|
||||
"https://pkgs.netbird.io/debian stable main",
|
||||
);
|
||||
await expect(modal).toContainText("sudo apt-get install netbird");
|
||||
await expect(modal).toContainText(
|
||||
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
|
||||
);
|
||||
// The run step stays present alongside the distro-specific steps
|
||||
await expect(modal.getByText("netbird up")).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("switches repository and install commands per distro", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openLinuxManualInstall(page);
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
|
||||
await selectDistro(page, "Fedora (DNF)");
|
||||
await expect(modal).toContainText("/etc/yum.repos.d/netbird.repo");
|
||||
await expect(modal).toContainText("sudo dnf install netbird");
|
||||
await expect(modal).toContainText(
|
||||
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
|
||||
);
|
||||
await expect(modal).not.toContainText("apt-get");
|
||||
await expect(modal).not.toContainText("epel-release");
|
||||
|
||||
await selectDistro(page, "RHEL / AlmaLinux / Rocky (DNF)");
|
||||
await expect(modal).toContainText("sudo dnf install epel-release -y");
|
||||
await expect(modal).toContainText(
|
||||
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
|
||||
);
|
||||
});
|
||||
|
||||
test("imports the repository GPG key on openSUSE", async ({ page }) => {
|
||||
await openLinuxManualInstall(page);
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
|
||||
await selectDistro(page, "openSUSE (Zypper)");
|
||||
await expect(modal).toContainText(
|
||||
"sudo zypper --non-interactive addrepo -f -g https://pkgs.netbird.io/yum/ netbird",
|
||||
);
|
||||
await expect(modal).toContainText(
|
||||
"sudo zypper --gpg-auto-import-keys refresh netbird",
|
||||
);
|
||||
await expect(modal).toContainText("sudo zypper install netbird");
|
||||
await expect(modal).toContainText(
|
||||
"sudo zypper install netbird-ui libgtk-4-1 libwebkitgtk-6_0-4 xdg-utils",
|
||||
);
|
||||
// Zypper ignores gpgkey= in a .repo file, so that snippet must not appear
|
||||
await expect(modal).not.toContainText("/etc/yum.repos.d/netbird.repo");
|
||||
});
|
||||
|
||||
test("offers CLI only on Amazon Linux", async ({ page }) => {
|
||||
await openLinuxManualInstall(page);
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
|
||||
await selectDistro(page, "Amazon Linux (YUM)");
|
||||
await expect(modal).toContainText("sudo yum install netbird");
|
||||
await expect(modal).not.toContainText("netbird-ui");
|
||||
await expect(modal).not.toContainText("for CLI only");
|
||||
await expect(modal).toContainText("only the CLI is available");
|
||||
});
|
||||
|
||||
test("copies install commands without comment lines", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await openLinuxManualInstall(page);
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
|
||||
// Copy buttons in DOM order: install script, run command, repository,
|
||||
// install step, run command (accordion).
|
||||
await modal.getByTestId("copy-to-clipboard").nth(3).click();
|
||||
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(copied, "clipboard should hold runnable commands only").toBe(
|
||||
[
|
||||
"sudo apt-get update",
|
||||
"sudo apt-get install netbird",
|
||||
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
@@ -20,9 +21,9 @@ test.describe.serial("Team - Service Users @team", () => {
|
||||
});
|
||||
|
||||
test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => {
|
||||
await page.locator("tr").getByText(regularUser).click();
|
||||
await openServiceUser(page, regularUser);
|
||||
await changeRoleTo(page, "Admin");
|
||||
await page.getByTestId("save-changes").click();
|
||||
await saveUserChanges(page);
|
||||
|
||||
// Create and delete access token
|
||||
const tokenName = generateRandomName("tkn_");
|
||||
@@ -37,23 +38,18 @@ test.describe.serial("Team - Service Users @team", () => {
|
||||
await tokenRow.getByTestId("access-token-delete").click();
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(tokenRow).not.toBeVisible();
|
||||
|
||||
await page.getByText("Service Users").first().click();
|
||||
});
|
||||
|
||||
test("Should update admin user role and verify all changes persisted", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await page.locator("tr").getByText(adminServiceUser).click();
|
||||
await openServiceUser(page, adminServiceUser);
|
||||
await changeRoleTo(page, "User");
|
||||
const saveResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes("/api/users/") && resp.request().method() === "PUT",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("save-changes").click();
|
||||
await saveResponse;
|
||||
await saveUserChanges(page);
|
||||
|
||||
await page.getByText("Service Users").first().click();
|
||||
// Go back the way a user would, so this also covers the save refreshing
|
||||
// the cached users list rather than only what the server persisted.
|
||||
await returnToServiceUserList(page);
|
||||
await checkServiceUserRow(page, regularUser, "Admin");
|
||||
await checkServiceUserRow(page, adminServiceUser, "User");
|
||||
|
||||
@@ -64,6 +60,7 @@ test.describe.serial("Team - Service Users @team", () => {
|
||||
});
|
||||
|
||||
test("Should delete service users", async ({ dashboardAsOwner: page }) => {
|
||||
await openServiceUserList(page);
|
||||
for (const name of [regularUser, adminServiceUser]) {
|
||||
const row = page.locator("tr").filter({ hasText: name });
|
||||
// Row actions are now behind a dropdown menu; open it, then delete.
|
||||
@@ -75,11 +72,7 @@ test.describe.serial("Team - Service Users @team", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function createServiceUser(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
role: string,
|
||||
) {
|
||||
async function createServiceUser(page: Page, name: string, role: string) {
|
||||
await page.getByTestId("open-service-user-modal").click();
|
||||
await expect(page.getByTestId("service-user-name")).toBeVisible({ timeout: 5_000 });
|
||||
await page.getByTestId("service-user-name").fill(name);
|
||||
@@ -93,23 +86,86 @@ async function createServiceUser(
|
||||
await expect(page.getByTestId("service-user-name")).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
async function checkServiceUserRow(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
role: string,
|
||||
) {
|
||||
// openServiceUserList lands on the service users table and waits for it to
|
||||
// settle.
|
||||
async function openServiceUserList(page: Page) {
|
||||
await navigateTo(page, "/team/service-users");
|
||||
await expectServiceUserList(page);
|
||||
}
|
||||
|
||||
// returnToServiceUserList goes back to the list from a user's page the way a
|
||||
// user would, via the breadcrumb. Unlike a plain click on the nav entry, it
|
||||
// waits for the navigation to actually land: the click itself resolves while
|
||||
// the client-side navigation is still in flight, so anything that follows can
|
||||
// otherwise be computed against the page being left behind.
|
||||
async function returnToServiceUserList(page: Page) {
|
||||
await page
|
||||
.getByTestId("breadcrumb-item")
|
||||
.filter({ hasText: "Service Users" })
|
||||
.click();
|
||||
await page.waitForURL(/\/team\/service-users/, { timeout: 15_000 });
|
||||
await expectServiceUserList(page);
|
||||
}
|
||||
|
||||
async function expectServiceUserList(page: Page) {
|
||||
await expect(page.getByRole("heading", { name: /Service Users/ })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
// openServiceUser opens one service user's page from the list and only returns
|
||||
// once that page really belongs to `name`. The row click is a client-side
|
||||
// navigation and the table re-renders as the users fetch revalidates, so
|
||||
// clicking a row without confirming where we landed can silently leave us on a
|
||||
// different user — and every later edit then targets that other user.
|
||||
async function openServiceUser(page: Page, name: string) {
|
||||
await openServiceUserList(page);
|
||||
|
||||
const row = page.locator("tr").filter({ hasText: name });
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
await row.click();
|
||||
|
||||
await page.waitForURL(/\/team\/user\?/, { timeout: 15_000 });
|
||||
await expect(page.getByRole("heading", { name, exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function checkServiceUserRow(page: Page, name: string, role: string) {
|
||||
const row = page.locator("tr").filter({ hasText: name });
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
await expect(row.getByText(role, { exact: true }).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function changeRoleTo(
|
||||
page: import("@playwright/test").Page,
|
||||
role: string,
|
||||
) {
|
||||
async function changeRoleTo(page: Page, role: string) {
|
||||
await page.getByTestId("user-role-selector").click();
|
||||
await page
|
||||
.getByTestId("user-role-selector-item")
|
||||
.getByText(role, { exact: true })
|
||||
.click();
|
||||
// Confirm the pick landed. A missed dropdown click leaves the role
|
||||
// unchanged, which disables "Save Changes" and turns the save below into an
|
||||
// unexplained 30s wait for a PUT that is never sent.
|
||||
await expect(
|
||||
page.getByTestId("user-role-selector").getByText(role, { exact: true }),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
// saveUserChanges saves the open user page and awaits the PUT, so the change is
|
||||
// persisted before the next serial test asserts it — clicking save alone
|
||||
// returns before the request lands. The PUT is pinned to the user currently
|
||||
// open and its status checked: a PUT for some other user, or a rejected one,
|
||||
// otherwise leaves the account in a state later assertions blame on the UI.
|
||||
async function saveUserChanges(page: Page) {
|
||||
const userId = new URL(page.url()).searchParams.get("id");
|
||||
expect(userId, "expected a user page with an id parameter").toBeTruthy();
|
||||
|
||||
const saveResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes(`/api/users/${userId}`) &&
|
||||
resp.request().method() === "PUT",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("save-changes").click();
|
||||
expect((await saveResponse).status()).toBe(200);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import prettier from "eslint-config-prettier";
|
||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||
|
||||
const config = [
|
||||
{
|
||||
ignores: [
|
||||
".claude/**",
|
||||
"playwright-report/**",
|
||||
"test-logs/**",
|
||||
"public/**",
|
||||
],
|
||||
},
|
||||
...nextCoreWebVitals,
|
||||
prettier,
|
||||
{
|
||||
files: ["**/*.{js,jsx,mjs,ts,tsx,mts,cts}"],
|
||||
plugins: { "simple-import-sort": simpleImportSort },
|
||||
rules: {
|
||||
"simple-import-sort/imports": [
|
||||
"warn",
|
||||
{
|
||||
groups: [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]],
|
||||
},
|
||||
],
|
||||
"simple-import-sort/exports": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default config;
|
||||
Generated
+643
-571
File diff suppressed because it is too large.
Load diff
+4
-7
@@ -9,11 +9,10 @@
|
||||
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
|
||||
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
|
||||
"dev": "next dev -p 3000",
|
||||
"turbo": "next dev -p 3000 --turbo",
|
||||
"build": "next build",
|
||||
"postbuild": "node postbuild.js",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"test:setup": "cd ./e2e/environment && sh create-test-env.sh",
|
||||
"test:clean": "cd ./e2e/environment && sh clean-test-env.sh",
|
||||
"test:dev": "cross-env APP_ENV=test next dev -p 1337",
|
||||
@@ -71,12 +70,10 @@
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"framer-motion": "^12.29.2",
|
||||
"ip-address": "^10.2.0",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.7",
|
||||
"lodash": "4.18.1",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"next": "16.3.0",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
@@ -103,11 +100,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"eslint-config-next": "^16.3.0",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3.4.17"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB |
@@ -17,6 +17,13 @@ function formatVersion(version: string): string {
|
||||
return version;
|
||||
}
|
||||
|
||||
// Self-hosted builds can carry a numeric build suffix (e.g. "0.76.3-31256681241")
|
||||
// that overflows the sidebar. Show the semver only and keep the rest for the
|
||||
// tooltip. Pre-release labels like "-rc.1" are left intact so they stay visible.
|
||||
function formatShortVersion(version: string): string {
|
||||
return formatVersion(version).replace(/^(v?\d+(?:\.\d+)*)-\d+$/, "$1");
|
||||
}
|
||||
|
||||
function compareVersions(current: string, latest: string): boolean {
|
||||
// Returns true if latest is newer than current
|
||||
if (!current || !latest) return false;
|
||||
@@ -98,9 +105,15 @@ const NavigationVersionInfoContent = () => {
|
||||
<div className="flex flex-col gap-1 text-nb-gray-400">
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
Latest: {formatVersion(versionInfo.management_available_version)}
|
||||
</span>
|
||||
<div className="text-xs flex flex-col gap-1">
|
||||
<span>
|
||||
Installed:{" "}
|
||||
{formatVersion(versionInfo.management_current_version)}
|
||||
</span>
|
||||
<span>
|
||||
Latest: {formatVersion(versionInfo.management_available_version)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
className="w-full"
|
||||
@@ -108,15 +121,18 @@ const NavigationVersionInfoContent = () => {
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Management</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatVersion(versionInfo.management_current_version)}
|
||||
{formatShortVersion(versionInfo.management_current_version)}
|
||||
</span>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
Latest: {formatVersion(versionInfo.dashboard_available_version)}
|
||||
</span>
|
||||
<div className="text-xs flex flex-col gap-1">
|
||||
<span>Installed: {formatVersion(dashboardVersion)}</span>
|
||||
<span>
|
||||
Latest: {formatVersion(versionInfo.dashboard_available_version)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
className="w-full"
|
||||
@@ -124,7 +140,7 @@ const NavigationVersionInfoContent = () => {
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Dashboard</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatVersion(dashboardVersion)}
|
||||
{formatShortVersion(dashboardVersion)}
|
||||
</span>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
|
||||
@@ -57,9 +57,9 @@ export default function DataTableHeader({
|
||||
{children}
|
||||
{sorting &&
|
||||
(column.getIsSorted() === "desc" ? (
|
||||
<IconSortAscending size={16} />
|
||||
) : (
|
||||
<IconSortDescending size={16} />
|
||||
) : (
|
||||
<IconSortAscending size={16} />
|
||||
))}
|
||||
</div>
|
||||
</FullTooltip>
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@/contexts/ThemeProvider";
|
||||
|
||||
export default function DarkModeToggle() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import dynamic from "next/dynamic";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import * as React from "react";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
const NextThemesProvider = dynamic(
|
||||
() => import("next-themes").then((mod) => mod.ThemeProvider),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export function GlobalThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
storageKey="netbird-theme"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
{...props}
|
||||
>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
{children}
|
||||
</SkeletonTheme>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
export type Theme = "dark" | "light" | "system";
|
||||
export type ResolvedTheme = Exclude<Theme, "system">;
|
||||
|
||||
const STORAGE_KEY = "netbird-theme";
|
||||
const DEFAULT_THEME: ResolvedTheme = "dark";
|
||||
const THEME_CLASSES: ResolvedTheme[] = ["dark", "light"];
|
||||
const SKELETON_COLORS: Record<
|
||||
ResolvedTheme,
|
||||
{ base: string; highlight: string }
|
||||
> = {
|
||||
dark: { base: "#25282d", highlight: "#33373e" },
|
||||
light: { base: "#e4e7e9", highlight: "#f4f6f7" },
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const ThemeContext = React.createContext(
|
||||
{} as {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
},
|
||||
);
|
||||
|
||||
function isTheme(value: string | null): value is Theme {
|
||||
return value === "dark" || value === "light" || value === "system";
|
||||
}
|
||||
|
||||
function systemTheme(): ResolvedTheme {
|
||||
return window.matchMedia("(prefers-color-scheme: light)").matches
|
||||
? "light"
|
||||
: "dark";
|
||||
}
|
||||
|
||||
function resolveTheme(theme: Theme): ResolvedTheme {
|
||||
return theme === "system" ? systemTheme() : theme;
|
||||
}
|
||||
|
||||
// storedTheme reads the persisted preference. Access to localStorage can throw
|
||||
// when the browser blocks storage, in which case we fall back to the default.
|
||||
function storedTheme(): Theme {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return isTheme(stored) ? stored : DEFAULT_THEME;
|
||||
} catch {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: Props) {
|
||||
// The theme class ships on <html> in the static export, so the initial paint
|
||||
// never flashes. Reading storage here only matters when the stored preference
|
||||
// differs from that default.
|
||||
const [theme, setThemeState] = useState<Theme>(storedTheme);
|
||||
// Tracked separately so "system" keeps the skeleton palette in step with the
|
||||
// OS preference, which can change while the app is open.
|
||||
const [resolvedTheme, setResolvedTheme] =
|
||||
useState<ResolvedTheme>(DEFAULT_THEME);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
setThemeState(next);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Preference is not persisted when storage is unavailable.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const apply = () => {
|
||||
const resolved = resolveTheme(theme);
|
||||
const root = document.documentElement;
|
||||
root.classList.remove(...THEME_CLASSES.filter((c) => c !== resolved));
|
||||
root.classList.add(resolved);
|
||||
root.style.colorScheme = resolved;
|
||||
setResolvedTheme(resolved);
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
if (theme !== "system") return;
|
||||
|
||||
const query = window.matchMedia("(prefers-color-scheme: light)");
|
||||
query.addEventListener("change", apply);
|
||||
return () => query.removeEventListener("change", apply);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
<SkeletonTheme
|
||||
baseColor={SKELETON_COLORS[resolvedTheme].base}
|
||||
highlightColor={SKELETON_COLORS[resolvedTheme].highlight}
|
||||
>
|
||||
{children}
|
||||
</SkeletonTheme>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
@@ -18,9 +18,9 @@ import AnalyticsProvider, {
|
||||
} from "@/contexts/AnalyticsProvider";
|
||||
import DialogProvider from "@/contexts/DialogProvider";
|
||||
import ErrorBoundaryProvider from "@/contexts/ErrorBoundary";
|
||||
import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider";
|
||||
import InstanceSetupProvider from "@/contexts/InstanceSetupProvider";
|
||||
import { NavigationEvents } from "@/contexts/NavigationEvents";
|
||||
import { ThemeProvider } from "@/contexts/ThemeProvider";
|
||||
import { useSignupSource } from "@/hooks/useSignupSource";
|
||||
|
||||
const inter = localFont({
|
||||
@@ -43,7 +43,7 @@ export default function AppLayout({
|
||||
useSignupSource();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className="dark" style={{ colorScheme: "dark" }}>
|
||||
<head>
|
||||
<GoogleTagManagerHeadScript />
|
||||
</head>
|
||||
@@ -51,7 +51,7 @@ export default function AppLayout({
|
||||
<Suspense fallback={<FullScreenLoading />}>
|
||||
<AnalyticsProvider>
|
||||
<DialogProvider>
|
||||
<GlobalThemeProvider>
|
||||
<ThemeProvider>
|
||||
<ErrorBoundaryProvider>
|
||||
<InstanceSetupProvider>
|
||||
<OIDCProvider>
|
||||
@@ -61,7 +61,7 @@ export default function AppLayout({
|
||||
</OIDCProvider>
|
||||
</InstanceSetupProvider>
|
||||
</ErrorBoundaryProvider>
|
||||
</GlobalThemeProvider>
|
||||
</ThemeProvider>
|
||||
</DialogProvider>
|
||||
<Toaster
|
||||
position="top-center"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useOidcUser } from "@axa-fr/react-oidc";
|
||||
import Button from "@components/Button";
|
||||
import { UserAvatar } from "@components/ui/UserAvatar";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetBirdCloud } from "@utils/netbird";
|
||||
import { useIsSm, useIsXs } from "@utils/responsive";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { XIcon } from "lucide-react";
|
||||
@@ -24,9 +25,8 @@ import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import UsersProvider from "@/contexts/UsersProvider";
|
||||
import Navigation from "@/layouts/Navigation";
|
||||
import Header, { headerHeight } from "./Header";
|
||||
import { OnboardingProvider } from "@/modules/onboarding/OnboardingProvider";
|
||||
import { isNetBirdCloud } from "@utils/netbird";
|
||||
import Header, { headerHeight } from "./Header";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@@ -175,7 +175,7 @@ function DashboardPageContent({
|
||||
}}
|
||||
>
|
||||
{!isRestricted && <Navigation hideOnMobile />}
|
||||
{children}
|
||||
<React.Fragment key={"page"}>{children}</React.Fragment>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
Loaded 20 of 51 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user