Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3cb76f7
Capture netbird.ai signup source before authentication
mlsmaycon Jul 12, 2026
b62a184
Resolve Agent Network mode from account settings
mlsmaycon Jul 12, 2026
b0b9c38
Enable Agent Network focused view for netbird.ai signups
mlsmaycon Jul 12, 2026
d4217db
Add Agent Network focused view toggle to client settings
mlsmaycon Jul 12, 2026
7dd95af
Swap remaining agent network gating call sites
mlsmaycon Jul 12, 2026
fb409b7
Use signup_source query parameter for signup source capture
mlsmaycon Jul 12, 2026
b280848
Resolve Agent Network mode optimistically for pending netbird.ai signups
mlsmaycon Jul 12, 2026
d16672f
Respect explicit agent_network_only opt-out over deployment config
mlsmaycon Jul 12, 2026
55f2847
Merge remote-tracking branch 'origin/main' into feature/agent-network…
mlsmaycon Jul 12, 2026
cc7b811
Add e2e spec for Agent Network focused view menu and routes
mlsmaycon Jul 12, 2026
8920b2d
Anchor focused-view e2e assertions on always-present nav to avoid bac…
mlsmaycon Jul 12, 2026
38dc2aa
Deflake focused-view e2e: assert route reachability instead of sideba…
mlsmaycon Jul 12, 2026
2aa59ca
Fix onboarding form closing when Agent Network focused view is applied
mlsmaycon Jul 12, 2026
7ad5be9
Show the agent-network signup form on cloud for netbird.ai signups
mlsmaycon Jul 12, 2026
dd35a33
Defer onboarding until Agent Network mode resolves to avoid wrong-for…
mlsmaycon Jul 12, 2026
0fff98c
Decide onboarding form from signup_source so the regular form never f…
mlsmaycon Jul 12, 2026
6c0a29b
Add e2e coverage for onboarding form selection
mlsmaycon Jul 12, 2026
0ef296f
Let loginToApp expect an onboarding overlay instead of dismissing the…
mlsmaycon Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions e2e/tests/agent-network-focused-view.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/**
* Agent Network focused-view spec.
*
* Exercises the account-driven focused view added for netbird.ai signups:
* the `agent_network_only` account setting hides the rest of the dashboard,
* an explicit `false` opts back out, and a pending netbird.ai signup applies
* the focused view optimistically and persists the setting via PUT /accounts.
*
* The test management backend does not know the setting, so GET /accounts is
* rewritten per-test to inject it (same interception approach as
* edition-gating.spec.ts). "only" mode is asserted through the Networks nav
* item, which toggles purely on the focused-view flag and does not depend on
* premium permission modules.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";

const SIGNUP_SOURCE_KEY = "netbird-signup-source";
const AGENT_NETWORK_SOURCE = "netbird.ai";

type AccountMock = {
// undefined leaves the setting absent (as an un-onboarded account would be).
agentNetworkOnly?: boolean;
signupFormPending?: boolean;
};

// mockAccounts rewrites GET /accounts to inject the focused-view setting and
// onboarding state onto the real account. A PUT to /accounts/{id} is captured
// and flips the injected setting to true, mirroring the server persisting the
// value so the optimistic view does not revert once the write settles.
function mockAccounts(
page: Page,
initial: AccountMock,
captured: { putBody?: any },
) {
let applied = initial.agentNetworkOnly;

page.route("**/api/accounts", async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
let body: any;
try {
body = await response.json();
} catch (e) {
return route.fulfill({ response });
}
if (Array.isArray(body) && body[0]) {
body[0].settings = { ...(body[0].settings ?? {}) };
if (applied === undefined) {
delete body[0].settings.agent_network_only;
} else {
body[0].settings.agent_network_only = applied;
}
body[0].onboarding = {
...(body[0].onboarding ?? {}),
signup_form_pending: !!initial.signupFormPending,
};
}
return route.fulfill({ response, json: body });
});

page.route("**/api/accounts/*", async (route) => {
if (route.request().method() !== "PUT") return route.continue();
try {
captured.putBody = route.request().postDataJSON();
} catch (e) {
captured.putBody = null;
}
applied = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ settings: { agent_network_only: true } }),
});
});
}

async function openWithAccount(
browser: Browser,
account: AccountMock,
opts: { signupSource?: boolean } = {},
): Promise<{
page: Page;
captured: { putBody?: any };
close: () => Promise<void>;
}> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
if (opts.signupSource) {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE],
);
}
const page = await context.newPage();
const captured: { putBody?: any } = {};
mockAccounts(page, account, captured);
await loginToApp(page, "owner");
return { page, captured, close: () => context.close() };
}

function navItem(page: Page, text: string) {
return page
.getByTestId("left-navigation-item")
.getByText(text, { exact: true });
}

// Regular dashboard sections that the focused view hides.
const REGULAR_NAV = ["Networks", "Reverse Proxy", "DNS", "Activity"];
// Agent Network views that make up the focused menu.
const AGENT_NAV_CHILDREN = [
"Providers",
"Policies",
"Usage & Logs",
"Configuration",
];

test.describe.serial("Agent Network focused view @agent-network", () => {
test("focused menu shows only Agent Network views and hides the regular sections", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: true,
});
try {
// The Agent Network section is present and the regular sections are gone.
await expect(navItem(page, "Agent Network")).toBeVisible();
for (const label of REGULAR_NAV) {
await expect(navItem(page, label)).toHaveCount(0);
}
// Core sections that are not part of the focused/regular split remain.
await expect(navItem(page, "Settings")).toBeVisible();

// The Agent Network views are reachable from the menu.
await navItem(page, "Agent Network").click();
for (const child of AGENT_NAV_CHILDREN) {
await expect(navItem(page, child)).toBeVisible();
}
} finally {
await close();
}
});

test("focused view keeps Agent Network routes reachable", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: true,
});
try {
// The route guard renders the view instead of redirecting away.
await navigateTo(page, "/agent-network/providers");
await expect(page).toHaveURL(/\/agent-network\/providers/);
await expect(navItem(page, "Agent Network")).toBeVisible();
await expect(navItem(page, "Networks")).toHaveCount(0);
} finally {
await close();
}
});

test("explicit opt-out restores the regular menu and removes Agent Network", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: false,
});
try {
// The regular sections come back...
for (const label of REGULAR_NAV) {
await expect(navItem(page, label)).toBeVisible();

Check failure on line 175 in e2e/tests/agent-network-focused-view.spec.ts

View workflow job for this annotation

GitHub Actions / playwright-run

[e2e] › e2e/tests/agent-network-focused-view.spec.ts:166:7 › Agent Network focused view @agent-network › explicit opt-out restores the regular menu and removes Agent Network

1) [e2e] › e2e/tests/agent-network-focused-view.spec.ts:166:7 › Agent Network focused view @agent-network › explicit opt-out restores the regular menu and removes Agent Network Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByTestId('left-navigation-item').getByText('Networks', { exact: true }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByTestId('left-navigation-item').getByText('Networks', { exact: true }) 173 | // The regular sections come back... 174 | for (const label of REGULAR_NAV) { > 175 | await expect(navItem(page, label)).toBeVisible(); | ^ 176 | } 177 | // ...and the Agent Network section is gone (not enabled by config here). 178 | await expect(navItem(page, "Agent Network")).toHaveCount(0); at /home/runner/work/dashboard/dashboard/e2e/tests/agent-network-focused-view.spec.ts:175:44

Check failure on line 175 in e2e/tests/agent-network-focused-view.spec.ts

View workflow job for this annotation

GitHub Actions / playwright-run

[e2e] › e2e/tests/agent-network-focused-view.spec.ts:166:7 › Agent Network focused view @agent-network › explicit opt-out restores the regular menu and removes Agent Network

1) [e2e] › e2e/tests/agent-network-focused-view.spec.ts:166:7 › Agent Network focused view @agent-network › explicit opt-out restores the regular menu and removes Agent Network Error: expect(locator).toBeVisible() failed Locator: getByTestId('left-navigation-item').getByText('Networks', { exact: true }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByTestId('left-navigation-item').getByText('Networks', { exact: true }) 173 | // The regular sections come back... 174 | for (const label of REGULAR_NAV) { > 175 | await expect(navItem(page, label)).toBeVisible(); | ^ 176 | } 177 | // ...and the Agent Network section is gone (not enabled by config here). 178 | await expect(navItem(page, "Agent Network")).toHaveCount(0); at /home/runner/work/dashboard/dashboard/e2e/tests/agent-network-focused-view.spec.ts:175:44

Check failure on line 175 in e2e/tests/agent-network-focused-view.spec.ts

View workflow job for this annotation

GitHub Actions / playwright-run

[e2e] › e2e/tests/agent-network-focused-view.spec.ts:166:7 › Agent Network focused view @agent-network › explicit opt-out restores the regular menu and removes Agent Network

1) [e2e] › e2e/tests/agent-network-focused-view.spec.ts:166:7 › Agent Network focused view @agent-network › explicit opt-out restores the regular menu and removes Agent Network Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByTestId('left-navigation-item').getByText('Networks', { exact: true }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByTestId('left-navigation-item').getByText('Networks', { exact: true }) 173 | // The regular sections come back... 174 | for (const label of REGULAR_NAV) { > 175 | await expect(navItem(page, label)).toBeVisible(); | ^ 176 | } 177 | // ...and the Agent Network section is gone (not enabled by config here). 178 | await expect(navItem(page, "Agent Network")).toHaveCount(0); at /home/runner/work/dashboard/dashboard/e2e/tests/agent-network-focused-view.spec.ts:175:44

Check failure on line 175 in e2e/tests/agent-network-focused-view.spec.ts

View workflow job for this annotation

GitHub Actions / playwright-run

[e2e] › e2e/tests/agent-network-focused-view.spec.ts:166:7 › Agent Network focused view @agent-network › explicit opt-out restores the regular menu and removes Agent Network

1) [e2e] › e2e/tests/agent-network-focused-view.spec.ts:166:7 › Agent Network focused view @agent-network › explicit opt-out restores the regular menu and removes Agent Network Error: expect(locator).toBeVisible() failed Locator: getByTestId('left-navigation-item').getByText('Networks', { exact: true }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByTestId('left-navigation-item').getByText('Networks', { exact: true }) 173 | // The regular sections come back... 174 | for (const label of REGULAR_NAV) { > 175 | await expect(navItem(page, label)).toBeVisible(); | ^ 176 | } 177 | // ...and the Agent Network section is gone (not enabled by config here). 178 | await expect(navItem(page, "Agent Network")).toHaveCount(0); at /home/runner/work/dashboard/dashboard/e2e/tests/agent-network-focused-view.spec.ts:175:44
}
// ...and the Agent Network section is gone (not enabled by config here).
await expect(navItem(page, "Agent Network")).toHaveCount(0);
} finally {
await close();
}
});

test("applies the focused view optimistically for a pending netbird.ai signup and persists it", async ({
browser,
}) => {
const { page, captured, close } = await openWithAccount(
browser,
{ agentNetworkOnly: undefined, signupFormPending: true },
{ signupSource: true },
);
try {
// Focused view is applied immediately, before the setting is persisted.
await expect(navItem(page, "Agent Network")).toBeVisible();
await expect(navItem(page, "Networks")).toHaveCount(0);

// The signup source is persisted as the account setting.
await expect
.poll(() => captured.putBody?.settings?.agent_network_only)
.toBe(true);

// The focused view is retained after the write settles.
await expect(navItem(page, "Networks")).toHaveCount(0);
} finally {
await close();
}
});

test("exposes the focused-view toggle in client settings", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: true,
});
try {
await navigateTo(page, "/settings?tab=clients");
await expect(page.getByTestId("agent-network-only")).toBeVisible();
} finally {
await close();
}
});
});
21 changes: 14 additions & 7 deletions src/app/(dashboard)/agent-network/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
"use client";

import { isAgentNetworkEnabled } from "@utils/netbird";
import { notFound } from "next/navigation";
import * as React from "react";
import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode";

// Gates the entire Agent Network route tree behind the NETBIRD_AGENT_NETWORK
// flag. When disabled, these routes don't exist as far as the user is
// concerned — the dashboard behaves exactly as it did without the feature.
// flag or the account-level agent_network_only setting. When disabled, these
// routes don't exist as far as the user is concerned — the dashboard behaves
// exactly as it did without the feature. Rendering waits for the account to
// load so accounts enabled via settings don't get a redirect flash.
export default function AgentNetworkLayout({
children,
}: {
children: React.ReactNode;
}) {
if (!isAgentNetworkEnabled()) {
notFound();
const { enabled, loading } = useAgentNetworkMode();

if (enabled) {
return <>{children}</>;
}
if (loading) {
return null;
}
return <>{children}</>;
}
notFound();
}
12 changes: 7 additions & 5 deletions src/app/(dashboard)/control-center/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import SquareIcon from "@components/SquareIcon";
import GetStartedTest from "@components/ui/GetStartedTest";
import { SmallBadge } from "@components/ui/SmallBadge";
import useFetchApi from "@utils/api";
import { isAgentNetworkEnabled, isAgentNetworkOnly } from "@utils/netbird";
import {
Background,
Edge,
Expand Down Expand Up @@ -54,6 +53,7 @@ import AIProvidersProvider, {
useAIProviders,
} from "@/modules/agent-network/AIProvidersProvider";
import { AIProviderId } from "@/modules/agent-network/data/mockData";
import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode";
import { FlowSelector, FlowView } from "@/modules/control-center/FlowSelector";
import { NetworkRoutingPeerCount } from "@/modules/control-center/NetworkRoutingPeerCount";
import { ControlCenterCurrentUserBadge } from "@/modules/control-center/user/ControlCenterCurrentUserBadge";
Expand Down Expand Up @@ -90,6 +90,8 @@ function ControlCenterView() {
const [layoutInitialized, setLayoutInitialized] = useState(false);
const [forceLayoutChange, setForceLayoutChange] = useState(false);
const { loggedInUser } = useLoggedInUser();
const { only: agentNetworkOnly, enabled: agentNetworkEnabled } =
useAgentNetworkMode();

const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
Expand Down Expand Up @@ -125,13 +127,13 @@ function ControlCenterView() {
"/agent-network/providers",
true,
true,
isAgentNetworkEnabled(),
agentNetworkEnabled,
);
const { data: agentPolicies } = useFetchApi<APIPolicy[]>(
"/agent-network/policies",
true,
true,
isAgentNetworkEnabled(),
agentNetworkEnabled,
);

// providerById lets the overlay look up a Provider's display payload
Expand Down Expand Up @@ -1995,7 +1997,7 @@ function ControlCenterView() {
{/* Networks is dropped as a top-level pivot in the
agent-network repackaging — keep the dropdown + per-network
chrome for everyone else so flag-off behaviour is unchanged. */}
{!isAgentNetworkOnly() && currentView === "networks" && (
{!agentNetworkOnly && currentView === "networks" && (
<div className={"w-64"}>
<SelectDropdown
variant={"secondary"}
Expand All @@ -2011,7 +2013,7 @@ function ControlCenterView() {
</div>
)}

{!isAgentNetworkOnly() && selectedNetwork && currentNetwork && (
{!agentNetworkOnly && selectedNetwork && currentNetwork && (
<NetworkRoutingPeerCount network={currentNetwork} />
)}
</div>
Expand Down
48 changes: 47 additions & 1 deletion src/cloud/contexts/NetBirdCloudProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import loadConfig from "@utils/config";
import { isNetBirdCloud } from "@utils/netbird";
import * as React from "react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useSWRConfig } from "swr";
import { Hubspot, submitHubspotForm } from "@/cloud/analytics/Hubspot";
import { AWSChoosePlan } from "@/cloud/aws/AWSChoosePlan";
Expand All @@ -11,8 +12,14 @@ import { useDomainCategory } from "@/cloud/cloud-hooks/useDomainCategory";
import HowDidYouHearAboutUs from "@/cloud/survey/HowDidYouHearAboutUs";
import { useAnalytics } from "@/contexts/AnalyticsProvider";
import { useBilling } from "@/contexts/BillingProvider";
import {
AGENT_NETWORK_SIGNUP_SOURCE,
SIGNUP_SOURCE_LOCAL_STORAGE_KEY,
} from "@/hooks/useSignupSource";
import type { Account } from "@/interfaces/Account";
import type { Group } from "@/interfaces/Group";
import { PlanTier } from "@/interfaces/Subscription";
import { useAccount } from "@/modules/account/useAccount";
import { OnboardingProvider } from "@/modules/onboarding/OnboardingProvider";

export const NetBirdCloudProvider = () => {
Expand All @@ -25,6 +32,9 @@ export const NetBirdCloudProvider = () => {
"/integrations/billing/aws/marketplace/enrich",
true,
).post;
const account = useAccount();
const accountRequest = useApiCall<Account>("/accounts", true).put;
const signupSourceApplied = useRef(false);

useEffect(() => {
try {
Expand All @@ -41,6 +51,42 @@ export const NetBirdCloudProvider = () => {
} catch (e) {}
}, []);

// Apply the netbird.ai signup source once the account is available. Only
// new accounts (signup form still pending) are switched to the Agent
// Network focused view — a stale flag from an existing user is discarded.
useEffect(() => {
if (!account || signupSourceApplied.current) return;
try {
const source = localStorage.getItem(SIGNUP_SOURCE_LOCAL_STORAGE_KEY);
if (source !== AGENT_NETWORK_SIGNUP_SOURCE) return;

if (
account.onboarding?.signup_form_pending !== true ||
account.settings?.agent_network_only === true
) {
localStorage.removeItem(SIGNUP_SOURCE_LOCAL_STORAGE_KEY);
return;
}

signupSourceApplied.current = true;
notify({
title: "Agent Network",
description: "Agent Network focused view enabled for your account.",
promise: accountRequest(
{
id: account.id,
settings: { ...account.settings, agent_network_only: true },
},
"/" + account.id,
).then(() => {
mutate("/accounts");
localStorage.removeItem(SIGNUP_SOURCE_LOCAL_STORAGE_KEY);
}),
loadingMessage: "Enabling Agent Network focused view...",
});
} catch (e) {}
}, [account]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const hasFreeOrTrialAWSPlan =
subscription?.plan_tier === PlanTier.FREE ||
subscription?.plan_tier === PlanTier.TRIAL;
Expand Down
Loading
Loading