Compare commits

...
6 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
38 changed files with 1163 additions and 765 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();
@@ -3,8 +3,8 @@
*
* The management API signals "account not bootstrapped" differently by
* generation: current servers answer GET /agent-network/settings with the
* defaults object carrying an empty cluster/subdomain/endpoint, older ones
* with 200 + a JSON null body, and the oldest with a 404.
* 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
@@ -22,13 +22,14 @@ const EMPTY_STATE_TEXT =
const BOOTSTRAPPED_ENDPOINT = "violet.eu.proxy.netbird.io";
// The defaults object current servers return before the account is
// bootstrapped: values present, cluster/subdomain/endpoint empty, no
// timestamps (no row has been persisted yet).
// 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 = {
cluster: "",
subdomain: "",
endpoint: "",
proxy_address: "",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
@@ -36,9 +37,9 @@ const UNBOOTSTRAPPED_DEFAULTS = {
};
const BOOTSTRAPPED_SETTINGS = {
cluster: "eu.proxy.netbird.io",
subdomain: "violet",
endpoint: BOOTSTRAPPED_ENDPOINT,
proxy_address: "eu.proxy.netbird.io",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
+82 -34
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,17 +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 the PUT so the role change is persisted before the next serial
// test asserts it — clicking save alone returns before the request lands.
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);
// Create and delete access token
const tokenName = generateRandomName("tkn_");
@@ -45,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");
@@ -72,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.
@@ -83,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);
@@ -101,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"
+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>
+2 -2
View File
@@ -53,7 +53,7 @@ export default function Navigation({
? "w-auto max-w-[22rem]"
: "w-[15rem] max-w-[15rem] min-w-[15rem] overflow-y-auto",
isNavigationCollapsed &&
"md:w-[70px] md:min-w-[70px] md:fixed md:overflow-hidden md:hover:w-[15rem] md:hover:max-w-[15rem] md:hover:min-w-[15rem] md:z-50",
"md:w-[64px] md:min-w-[64px] md:fixed md:overflow-hidden md:hover:w-[15rem] md:hover:max-w-[15rem] md:hover:min-w-[15rem] md:z-50",
)}
style={{
height: `calc(100vh - ${headerHeight + bannerHeight}px)`,
@@ -71,7 +71,7 @@ export default function Navigation({
className={cn(
"flex flex-col pt-3 justify-between w-[15rem] max-w-[15rem] min-w-[15rem] transition-all",
isNavigationCollapsed &&
"md:w-[70px] md:min-w-[70px] md:group-hover/navigation:w-[15rem] md:group-hover/navigation:max-w-[15rem] md:group-hover/navigation:min-w-[15rem] md:overflow-x-clip",
"md:w-[64px] md:min-w-[64px] md:group-hover/navigation:w-[15rem] md:group-hover/navigation:max-w-[15rem] md:group-hover/navigation:min-w-[15rem] md:overflow-x-clip",
)}
style={{
height: !fullWidth
+1 -1
View File
@@ -16,7 +16,7 @@ export default function PageContainer({
className={cn(
className,
"relative flex-auto overflow-auto bg-nb-gray z-1 focus:outline-none",
isNavigationCollapsed && "md:pl-[70px]",
isNavigationCollapsed && "md:pl-[64px]",
)}
>
{children}
+23 -10
View File
@@ -170,18 +170,22 @@ export default function AIProviderModal({
onOpenChange,
provider,
}: Readonly<Props>) {
const { addProvider, updateProvider, settings } = useAIProviders();
const {
addProvider,
updateProvider,
settings,
bootstrapAgentNetworkSettings,
} = useAIProviders();
const { data: domains, isLoading: domainsLoading } = useFetchApi<
ReverseProxyDomain[]
>("/reverse-proxies/domains");
const { catalog: catalogList, getById } = useProviderCatalog();
const isEdit = !!provider;
// Cluster is no longer a per-provider concern: the backend pins it on
// the account-level Settings row, seeded by the first provider create.
// We auto-pick from the live /domains response and ship it as
// bootstrap_cluster on the create payload — the backend ignores it on
// subsequent creates and updates.
// The endpoint lives on the account-level Settings row, bootstrapped once
// via an explicit POST. We auto-pick a proxy cluster from the live /domains
// response and, when the account isn't bootstrapped yet, POST it as the
// settings proxy_address right before the first provider create.
const settingsBootstrapped = !!settings;
const [tab, setTab] = useState<string>("provider");
@@ -309,7 +313,7 @@ export default function AIProviderModal({
// Auto-pick the first validated cluster on first render once the
// /domains response lands. Only matters for the first-create flow —
// once settings is bootstrapped the bootstrap hint is ignored.
// once settings is bootstrapped no further bootstrap happens.
React.useEffect(() => {
if (settingsBootstrapped) return;
if (bootstrapCluster) return;
@@ -393,8 +397,8 @@ export default function AIProviderModal({
name.trim().length > 0 &&
/^https?:\/\/[^\s]+$/i.test(upstreamUrl.trim()) &&
apiKey.trim().length >= 4 &&
// First-create requires a cluster pick; once settings is bootstrapped
// the bootstrap hint is ignored so we don't need to gate on it.
// First-create bootstraps the settings row and needs a cluster pick;
// once settings is bootstrapped no cluster is involved.
(settingsBootstrapped || bootstrapCluster.trim().length > 0);
// Restrict extraValues to keys the current catalog entry declares.
@@ -453,11 +457,20 @@ export default function AIProviderModal({
handleClose();
return;
}
// First create: bootstrap the account's endpoint before the provider
// exists, as an explicit settings POST. A failure keeps the wizard open
// (the provider isn't created either) so the operator sees the error and
// can retry — this used to be a silent backend side effect.
if (!settingsBootstrapped) {
const bootstrapped = await bootstrapAgentNetworkSettings(
bootstrapCluster.trim(),
);
if (!bootstrapped) return;
}
await addProvider({
providerId,
name,
upstreamUrl,
bootstrapCluster: settingsBootstrapped ? undefined : bootstrapCluster,
apiKey,
extraValues: sanitizedExtraValues,
...identityOverrides,
@@ -69,9 +69,6 @@ export type APIProviderRequest = {
provider_id: string;
name: string;
upstream_url: string;
// bootstrap_cluster is only honoured on the first provider create
// for the account; subsequent creates and all updates ignore it.
bootstrap_cluster?: string;
api_key?: string;
extra_values?: Record<string, string>;
identity_header_user_id?: string;
@@ -86,9 +83,6 @@ export type ProviderConnectInput = {
providerId: AIProviderId;
name: string;
upstreamUrl: string;
// bootstrapCluster is only used on first-create to seed the
// account-level Settings row; ignored after that.
bootstrapCluster?: string;
apiKey: string;
extraValues?: Record<string, string>;
identityHeaderUserId?: string;
@@ -116,9 +110,13 @@ export type ProviderUpdateInput = {
};
export type APIAgentNetworkSettings = {
cluster: string;
subdomain: string;
// Bare hostname agents call for this account. Empty until bootstrapped.
endpoint: string;
// Declared cluster address of the proxy serving the endpoint: equal to
// endpoint when a dedicated proxy serves the account, otherwise the shared
// cluster the endpoint hangs one label beneath.
proxy_address: string;
dedicated: boolean;
attribution_mode?: AttributionMode;
default_user_monthly_budget?: number;
enable_log_collection: boolean;
@@ -131,10 +129,25 @@ export type APIAgentNetworkSettings = {
updated_at?: string;
};
// APIAgentNetworkSettingsCreateRequest matches the POST /agent-network/settings
// bootstrap body. Exactly one of proxy_address (labeled endpoint — the server
// allocates the label beneath it) and endpoint (self-addressed dedicated
// endpoint, claimed verbatim) must be set. The dashboard only uses the
// labeled shape; the dedicated shape is API/BYOP-facing.
export type APIAgentNetworkSettingsCreateRequest = {
proxy_address?: string;
endpoint?: string;
};
// APIAgentNetworkSettingsRequest matches the PUT /agent-network/settings
// body. Read-only fields (cluster, subdomain, endpoint, timestamps) are
// stamped by the backend and never sent from the dashboard.
// body. Every field is required — the PUT is a full replace, like the rest of
// the REST API. The identity fields (endpoint, proxy_address) are assigned at
// bootstrap (POST) and immutable: the request must echo the stored values
// unchanged, and the backend rejects a mismatch (422) without applying
// anything. Timestamps are stamped by the backend.
export type APIAgentNetworkSettingsRequest = {
endpoint: string;
proxy_address: string;
enable_log_collection: boolean;
enable_prompt_collection: boolean;
redact_pii: boolean;
@@ -142,9 +155,9 @@ export type APIAgentNetworkSettingsRequest = {
};
export type AgentNetworkSettings = {
cluster: string;
subdomain: string;
endpoint: string;
proxyAddress: string;
dedicated: boolean;
attributionMode: AttributionMode;
defaultUserMonthlyBudget: number;
enableLogCollection: boolean;
@@ -221,7 +234,6 @@ function toCreateRequest(input: ProviderConnectInput): APIProviderRequest {
provider_id: input.providerId,
name: input.name,
upstream_url: input.upstreamUrl,
bootstrap_cluster: input.bootstrapCluster,
api_key: input.apiKey,
extra_values: input.extraValues,
identity_header_user_id: input.identityHeaderUserId,
@@ -235,9 +247,9 @@ function toCreateRequest(input: ProviderConnectInput): APIProviderRequest {
function settingsFromAPI(s: APIAgentNetworkSettings): AgentNetworkSettings {
return {
cluster: s.cluster,
subdomain: s.subdomain,
endpoint: s.endpoint,
proxyAddress: s.proxy_address,
dedicated: s.dedicated ?? false,
attributionMode: s.attribution_mode ?? "priority",
defaultUserMonthlyBudget: s.default_user_monthly_budget ?? 0,
enableLogCollection: s.enable_log_collection ?? false,
@@ -249,8 +261,13 @@ function settingsFromAPI(s: APIAgentNetworkSettings): AgentNetworkSettings {
function settingsToRequest(
s: AgentNetworkSettingsUpdate,
identity: Pick<AgentNetworkSettings, "endpoint" | "proxyAddress">,
): APIAgentNetworkSettingsRequest {
return {
// Required echo of the immutable identity — always the stored values,
// never user input, so the backend's mismatch rejection can't trigger.
endpoint: identity.endpoint,
proxy_address: identity.proxyAddress,
enable_log_collection: s.enableLogCollection,
enable_prompt_collection: s.enablePromptCollection,
redact_pii: s.redactPii,
@@ -510,6 +527,12 @@ type AIProvidersContextValue = {
updateAgentNetworkSettings: (
updates: AgentNetworkSettingsUpdate,
) => Promise<boolean>;
// Bootstraps the account's settings row, assigning the endpoint as a
// server-allocated label beneath the given proxy cluster address. One-time
// per account; resolves to true once the row exists (a lost race against a
// concurrent bootstrap counts — the row is there), false on failure (errors
// are notified internally).
bootstrapAgentNetworkSettings: (proxyAddress: string) => Promise<boolean>;
};
const AIProvidersContext = createContext<AIProvidersContextValue | null>(null);
@@ -523,12 +546,12 @@ export function useAIProviders() {
}
// useAgentNetworkSettings fetches the account-level agent-network settings.
// Returns null until the account is bootstrapped (first provider create, or
// a settings update carrying a cluster). Backends signal the unbootstrapped
// state differently by age: current ones respond 200 with the defaults and
// an empty cluster/subdomain/endpoint, older ones 200 + JSON null, and the
// oldest 404 — tolerated via ignoreError so old deploys don't surface a
// spurious error in the empty state. All three normalize to null here.
// Returns null until the account is bootstrapped via the explicit settings
// POST. Backends signal the unbootstrapped state differently by age: current
// ones respond 200 with the defaults and an empty endpoint/proxy_address,
// older ones 200 + JSON null, and the oldest 404 — tolerated via ignoreError
// so old deploys don't surface a spurious error in the empty state. All
// three normalize to null here.
export function useAgentNetworkSettings() {
const { enabled: agentNetworkEnabled } = useAgentNetworkMode();
const { data, error, isLoading, mutate } =
@@ -607,6 +630,13 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
const settingsApi = useApiCall<APIAgentNetworkSettings>(
"/agent-network/settings",
);
// Separate call with ignoreError: the bootstrap treats a 409 (a concurrent
// bootstrap won) as success, so the global error surface must not fire
// before we get to classify the failure.
const settingsBootstrapApi = useApiCall<APIAgentNetworkSettings>(
"/agent-network/settings",
true,
);
const providers = useMemo<AIProvider[]>(
() => (apiProviders ?? []).map(fromAPI),
@@ -638,10 +668,6 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
try {
const created = await providersApi.post(toCreateRequest(input));
await mutate();
// First-create bootstraps account-level settings on the
// backend; refresh so the page header flips out of the empty
// state without a manual reload.
await mutateSettings();
notify({
title: "AI provider connected",
description: `${created.name} is now available on your agent network endpoint.`,
@@ -655,7 +681,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
return undefined;
}
},
[providersApi, mutate, mutateSettings],
[providersApi, mutate],
);
const updateProvider = useCallback(
@@ -966,10 +992,46 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
[budgetRulesApi, mutateBudgetRules],
);
const bootstrapAgentNetworkSettings = useCallback(
async (proxyAddress: string) => {
const body: APIAgentNetworkSettingsCreateRequest = {
proxy_address: proxyAddress,
};
try {
await settingsBootstrapApi.post(body);
} catch (err) {
const code = (err as { code?: number })?.code;
if (code !== 409) {
notify({
title: "Failed to set up the agent network endpoint",
description: err instanceof Error ? err.message : String(err),
});
return false;
}
// 409: a concurrent bootstrap won. The row exists, which is what
// the caller wanted — fall through to the refresh.
}
await mutateSettings();
return true;
},
[settingsBootstrapApi, mutateSettings],
);
const updateAgentNetworkSettings = useCallback(
async (updates: AgentNetworkSettingsUpdate) => {
// The PUT must echo the immutable identity fields (endpoint,
// proxy_address) alongside the mutable ones. Without a loaded settings
// row there is nothing to echo — and no row to update; the backend
// would 404 the PUT anyway.
if (!settings) {
notify({
title: "Failed to update account controls",
description: "Agent Network has not been set up yet.",
});
return false;
}
try {
await settingsApi.put(settingsToRequest(updates));
await settingsApi.put(settingsToRequest(updates, settings));
await mutateSettings();
notify({
title: "Account controls updated",
@@ -984,7 +1046,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
return false;
}
},
[settingsApi, mutateSettings],
[settings, settingsApi, mutateSettings],
);
const value = useMemo<AIProvidersContextValue>(
@@ -1016,6 +1078,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
toggleBudgetRule,
deleteBudgetRule,
updateAgentNetworkSettings,
bootstrapAgentNetworkSettings,
}),
[
providers,
@@ -1045,6 +1108,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
toggleBudgetRule,
deleteBudgetRule,
updateAgentNetworkSettings,
bootstrapAgentNetworkSettings,
],
);
@@ -20,7 +20,7 @@ import { Textarea } from "@components/Textarea";
import InputDomain, { domainReducer } from "@components/ui/InputDomain";
import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import cidr from "ip-cidr";
import { isValidIP } from "@utils/ip";
import { uniqueId } from "lodash";
import {
ExternalLinkIcon,
@@ -601,7 +601,7 @@ function NameserverInput({
if (ip == "") {
return "";
}
const validCIDR = cidr.isValidAddress(ip);
const validCIDR = isValidIP(ip);
if (!validCIDR) {
onError && onError(true);
return "Please enter a valid IP, e.g., 192.168.1.0";
@@ -360,11 +360,9 @@ export function SetupContent({
<Steps>
<Steps.Step step={1}>
<p>
Once the application is created, you&apos;ll be redirected to a
getting started page. Click <Mark>Get started</Mark> in the{" "}
<Mark>Provision User Accounts</Mark> section. Under the Create
configuration section, click{" "}
<Mark>Connect your application</Mark>
Once the application is created, click <Mark>Manage</Mark>, then
click <Mark>Provisioning</Mark>. Under the Create configuration
section, click <Mark>Connect your application</Mark>
</p>
<Lightbox image={entraGetStarted} />
</Steps.Step>
@@ -431,8 +429,8 @@ export function SetupContent({
<Steps>
<Steps.Step step={1}>
<p>
Navigate to the <Mark>Attribute mapping</Mark> section and click{" "}
<Mark>Provision Microsoft Entra ID Groups</Mark>
Navigate to the <Mark>Attribute mapping</Mark> section and
select the <Mark>Groups</Mark> tab
</p>
</Steps.Step>
<Steps.Step step={2}>
@@ -462,8 +460,8 @@ export function SetupContent({
<Steps>
<Steps.Step step={1}>
<p>
Navigate to the <Mark>Attribute mapping</Mark> section and click{" "}
<Mark>Provision Microsoft Entra ID Users</Mark>
Navigate to the <Mark>Attribute mapping</Mark> section and
select the <Mark>Users</Mark> tab
</p>
</Steps.Step>
<Steps.Step step={2}>
@@ -541,9 +539,9 @@ export function SetupContent({
<Steps.Step step={1}>
<p>
After assigning users and groups, navigate back to the
provisioning configuration and click the{" "}
<Mark>Start provisioning</Mark> button to enable automatic
synchronization
provisioning configuration, click <Mark>Overview</Mark>, then
click the <Mark>Start provisioning</Mark> button to enable
automatic synchronization
</p>
<Lightbox image={entraStartProvisioning} />
</Steps.Step>
Loaded 20 of 38 files, more files were not shown because too many files have changed in this diff. Show more