Compare commits

...
11 Commits
Author SHA1 Message Date
Eduard GertandClaude Opus 5 fb05f2c172 chore(docker): upgrade base image to Alpine 3.24 (#756)
Alpine 3.14 reached end of life in May 2023 and has received no security
fixes for roughly three years. nginx is installed from the Alpine repos
rather than pinned, so the base bump also moves nginx 1.20.2 -> 1.30.4,
certbot 1.16.0 -> 5.6.0 and supervisor 4.2.2 -> 4.3.0.

Alpine 3.24 carries security support until June 2028.

Verified by building and running this Dockerfile on 3.14, 3.23 and 3.24 and
comparing: the served index.html is byte-identical across all three, the CSP
response header hashes identically, `nginx -t` passes on the existing config,
and supervisord reaches the same state. /etc/crontabs/root and
/etc/nginx/http.d/ still exist on 3.24, so the `rm /etc/crontabs/root` step and
the config COPY paths are unaffected. Every certbot flag used by init_cert.sh
is still accepted by certbot 5.x.

Certificate issuance itself could not be exercised without a real domain, so
the certbot 1.x -> 5.x jump should get one staging issuance before rollout.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:12:03 +02:00
Eduard GertandClaude Opus 5 6cf828ce8a Upgrade Next.js to 16.3 (#755)
* chore: upgrade to Next.js 16.3

Turbopack is now the default for both `next dev` and `next build`, so the
separate `turbo` script is redundant and has been dropped.

`next build` rewrites tsconfig `moduleResolution` to "bundler", which Next 16
expects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: migrate ESLint to flat config

`next lint` was removed in Next 16, so `npm run lint` had been failing with
"Invalid project directory provided, no such directory: ./lint" since that
upgrade. eslint-config-next 16 also ships flat config only, which the old
.eslintrc.json could not consume.

Ignores .claude/ so stray worktree copies of the repo are not linted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: replace ip-cidr with ip-address to clear CVE advisories

ip-cidr pulled a vulnerable ip-address (GHSA-mwp4-54f8-5fhr, high: leading-zero
octets decoded as decimal while resolvers read them as octal, enabling SSRF;
GHSA-v2v4-37r5-5v8g, moderate: XSS in Address6 HTML methods). npm reported "no
fix available" because even the latest ip-cidr pins ip-address ^9.0.5, still
inside the vulnerable range.

ip-address 10.x was already a direct dependency and covers every use: its
isValid rejects leading-zero octets and validates mask ranges natively, and
isInSubnet replaces the one `new cidr().contains()` call site.

The new validators were differential-tested against the real ip-cidr over a
67-input corpus (134 checks) before removal, with no divergences; containment
was checked across 14 v4/v6/boundary/cross-family cases. `npm audit` now
reports 0 vulnerabilities.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: replace next-themes with an in-repo ThemeProvider

next-themes renders an inline <script> to set the theme before paint. It was
loaded through dynamic(ssr: false), so that script rendered on the client, where
React 19 never executes script tags and warns:

  Encountered a script tag while rendering React component.

Upgrading would not have helped; 0.4.6 still renders the script
unconditionally. The script was also useless here: injected after hydration, it
could not prevent a flash, and postbuild.js hoists inline scripts out of the
export anyway.

The replacement keeps the same `netbird-theme` storage key and three-mode
support, and sets the theme class on <html> in the layout instead, so the
static export ships the correct theme in its first byte with no script at all.

Dropping the dynamic(ssr: false) wrapper removed an implicit prerender bailout
for the whole provider tree, which exposed isNetBirdCloud() reading
window.location during the build-time render; it is now guarded. The exported
HTML is byte-identical either way, since the page already bails to client
rendering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: key the router children slot in the dashboard layout

React logged "Each child in a list should have a unique key prop. Check the
render method of DashboardPageContent." The App Router's segment element for
`children` arrives with a null key, which React flags once it is reconciled
alongside the Navigation sibling. Wrapping it in a keyed Fragment resolves it.

Verified in the browser both ways: with the Fragment the console is clean,
without it the warning returns.

Also applies the pending simple-import-sort fix for this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: match IPv4-mapped IPv6 hosts against IPv4 CIDRs

Regression from replacing ip-cidr. Its contains() normalised an IPv4-mapped
IPv6 address to the embedded v4 before the subnet check, so
contains("::ffff:10.0.0.5") on 10.0.0.0/24 returned true; isHostInCIDR returned
false. Verified against ip-cidr@3.1.0 directly.

This is reachable: isValidIP accepts "::ffff:10.0.0.5", so a mapped host entered
in ReverseProxyAddressInput against an IPv4 resource CIDR was wrongly rejected.

An IPv6 network still rejects a mapped host, so families do not leak. Checked
across 20 cases (6 mapped-host, 14 pre-existing), with the mapped expectations
taken from measured ip-cidr output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: derive skeleton colors from the resolved theme

SkeletonTheme had the dark palette hardcoded, so a light theme would render
dark skeletons. The light values are the ones already defined on the
feature-light-theme branch rather than new invented colors.

resolvedTheme is tracked in state and updated from the existing apply(), so the
palette also follows the OS preference under "system".

Latent today, since DarkModeToggle disables both Light and System.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:11:48 +02:00
Eduard GertandClaude Opus 5 83cb07a44c Fix icon alignment in collapsed navigation and network tab Add buttons (#748)
* fix: center icons in collapsed navigation rail

Nav items have 24px of left padding (px-3 on the li plus px-3 on the
button), so with 16px icons the icon center lands at 32px. The 70px rail
centered at 35px, leaving icons 3px off. Narrow the rail to 64px so the
two line up, and match the content offset in PageContainer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: use one plus icon across network tab Add buttons

The Resources and Routing Peers Add buttons used tabler's IconCirclePlus
while the Services tab used lucide's PlusCircle. Both were size 16, but
the two icon sets draw at different inner scales, so the glyphs read as
different sizes. Standardize on lucide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:11:33 +02:00
Viktor Liu bbfa2d3a79 Bump default wasm client to v0.76.3 (#754) 2026-08-11 17:17:00 +02:00
Brad Ison a92816b9b8 Bootstrap Agent Network settings via the explicit POST (#746)
Adopts the new Agent Network settings contract:

- The management API replaced the implicit settings bootstrap — a
  `bootstrap_cluster` hint on the first provider create — with an
  explicit POST /agent-network/settings carrying the proxy cluster
  address. The settings response now carries endpoint +
  proxy_address + dedicated instead of cluster + subdomain.

- The provider wizard keeps its UX (auto-picking a validated cluster,
  gating the first create on having one) but now bootstraps the
  settings row with the explicit POST right before the first provider
  create. A bootstrap failure keeps the wizard open and surfaces the
  error — previously a failed bootstrap was swallowed server-side and
  the account silently ended up with a provider but no endpoint. A 409
  (concurrent bootstrap won) is treated as success, since the row
  exists either way.

- Types updated accordingly; bootstrapAgentNetworkSettings is exposed
  on the providers context. The onboarding provider step reuses the
  same wizard, so it is covered by the same change.

Without this, the dashboard against a new management silently stops
bootstrapping accounts: provider create succeeds (the old hint is an
ignored unknown field), but no endpoint is ever assigned.
2026-08-11 11:32:27 +02:00
Bethuel Mmbaga 0caf754897 Update Entra ID SCIM setup wizard for the new Azure portal UI (#750) 2026-08-10 11:12:40 +02:00
Misha Bragin 49582fff74 Trim numeric build suffix from sidebar version display. (#749)
Self-hosted management versions can carry a numeric build suffix
  (e.g. 0.76.3-31256681241) that overflows the sidebar box. Show the
  semver only and move the full string into the hover tooltip alongside
  the latest version. Pre-release labels like -rc.1 are preserved.
2026-08-09 19:20:49 +02:00
Jack Carter af7745a03b chore: remove solutions engineer booking modal from onboarding (#745)
The onboarding survey opened a HubSpot booking modal for accounts with
50+ planned users. Removes the modal, its onboarding-call experiment
variants, the trigger in Onboarding.tsx, and the now-unused avatar.

Keeps src/cloud/cloud-hooks/useExperiment.ts, which this was the only
consumer of, for a future experiment.
2026-08-06 10:48:31 +02:00
Maycon Santos 19d82cc2ab Handle agent-network settings defaults response (#739)
* Handle agent-network settings defaults response

The management API now answers the settings GET with a defaults object
before the account is bootstrapped (empty cluster/subdomain/endpoint)
instead of a JSON null body. Normalize that shape to the existing null
settings signal so every consumer keeps a single not-bootstrapped check,
while still tolerating the null body and 404 from older backends.

* Pin the settings wire-shape normalization in a Playwright spec

The providers page must render the connect-first empty state for every
unbootstrapped settings shape the management API has answered with over
time (defaults object with an empty endpoint, JSON null body, 404) and
the endpoint badge once bootstrapped. The settings route is mocked per
test so the spec is independent of the backend build and of account
state left behind by other suites.

* Address review: no stale settings on 404, close context on setup failure

useFetchApi keeps the previous SWR data alongside the error, so a later
404 could expose stale settings through the memo; it now yields null
when the error is a 404 while still riding out transient failures on
the kept data. The wire-shapes spec closes its browser context when a
setup step throws before the close callback is handed to the test.
2026-08-04 01:59:34 +02:00
Misha Bragin b66cc4ccb7 Cache pricing + custom model entry (#736) 2026-08-01 15:56:24 +02:00
Zoltan Papp 1d408f2e7e Add openSUSE zypper instructions with GPG key import to the Linux tab (#735)
Zypper ignores the gpgkey= entry of a .repo file, so the yum snippet would
leave the repository key uninstalled on openSUSE. The new distro entry adds
the repository with GPG checking enabled and imports the key on refresh.
2026-07-28 17:50:09 +02:00
51 changed files with 1976 additions and 1221 deletions

No files matched your search

-13
View File
@@ -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
View File
@@ -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 && \
+25
View File
@@ -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
);
}
+40 -2
View File
@@ -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();
}
});
});
+73 -4
View File
@@ -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,
}) => {
+8
View File
@@ -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 })
+19
View File
@@ -69,6 +69,25 @@ test.describe("Setup modal Linux distro instructions", () => {
);
});
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");
+82 -26
View File
@@ -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);
}
+31
View File
@@ -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;
+643 -571
View File
File diff suppressed because it is too large. Load diff
+4 -7
View File
@@ -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

+24 -8
View File
@@ -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>
+2 -2
View File
@@ -57,9 +57,9 @@ export default function DataTableHeader({
{children}
{sorting &&
(column.getIsSorted() === "desc" ? (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
) : (
<IconSortAscending size={16} />
))}
</div>
</FullTooltip>
+1 -1
View File
@@ -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);
-32
View File
@@ -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>
);
}
+108
View File
@@ -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);
+4 -4
View File
@@ -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"
+3 -3
View File
@@ -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