diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index 2e7e82c1..28ca10c9 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -20,7 +20,11 @@ const credentials: Record = { /** * Navigate to the app, authenticate via Zitadel, and wait for the app to load. */ -export async function loginToApp(page: Page, user: TestUser = "owner") { +export async function loginToApp( + page: Page, + user: TestUser = "owner", + opts: { expectOnboarding?: boolean } = {}, +) { const { username, password } = credentials[user]; await page.goto("/"); @@ -39,7 +43,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") { const which = await Promise.race([ appReady.waitFor({ timeout: 20_000 }).then(() => "app" as const), setupModal.waitFor({ timeout: 20_000 }).then(() => "modal" as const), - approvalPending.waitFor({ timeout: 20_000 }).then(() => "approval" as const), + approvalPending + .waitFor({ timeout: 20_000 }) + .then(() => "approval" as const), onboarding.waitFor({ timeout: 20_000 }).then(() => "onboarding" as const), selectAccount.waitFor({ timeout: 20_000 }).then(() => "select" as const), loginInput.waitFor({ timeout: 20_000 }).then(() => "login" as const), @@ -51,6 +57,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") { } if (which === "modal") { + // Onboarding tests render a full-screen overlay over the setup modal; + // clicking modal-close would be pointer-intercepted, so leave it be. + if (opts.expectOnboarding) return; await setupModal.getByTestId("modal-close").click(); await expect(setupModal).not.toBeVisible(); return; @@ -89,8 +98,12 @@ export async function loginToApp(page: Page, user: TestUser = "owner") { onboarding.waitFor({ timeout: 15_000 }), ]); - // Dismiss setup modal if present - if (await setupModal.isVisible().catch(() => false)) { + // Dismiss setup modal if present, unless an onboarding overlay is expected + // on top of it (clicking through it would be pointer-intercepted). + if ( + !opts.expectOnboarding && + (await setupModal.isVisible().catch(() => false)) + ) { await setupModal.getByTestId("modal-close").click(); await expect(setupModal).not.toBeVisible(); } diff --git a/e2e/tests/agent-network-focused-view.spec.ts b/e2e/tests/agent-network-focused-view.spec.ts new file mode 100644 index 00000000..2a0d559c --- /dev/null +++ b/e2e/tests/agent-network-focused-view.spec.ts @@ -0,0 +1,220 @@ +/** + * 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; +}> { + 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 (gated on +// !agentNetworkOnly in Navigation.tsx). +const FOCUS_HIDDEN_NAV = ["Networks", "Reverse Proxy", "DNS", "Activity"]; +// Agent Network view routes that make up the focused surface. +const AGENT_VIEW_PATHS = [ + "/agent-network/providers", + "/agent-network/policies", + "/agent-network/usage", + "/agent-network/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 { + // Peers always renders for an owner and is not part of the focused + // split, so it confirms the sidebar rendered before asserting absences. + await expect(navItem(page, "Peers")).toBeVisible(); + + // The Agent Network section is present and the regular sections are gone. + await expect(navItem(page, "Agent Network")).toBeVisible(); + for (const label of FOCUS_HIDDEN_NAV) { + await expect(navItem(page, label)).toHaveCount(0); + } + } finally { + await close(); + } + }); + + test("focused view keeps every Agent Network route reachable", async ({ + browser, + }) => { + const { page, close } = await openWithAccount(browser, { + agentNetworkOnly: true, + }); + try { + // Each Agent Network view renders instead of the route guard redirecting + // away; the Agent Network section stays in the sidebar throughout. + for (const path of AGENT_VIEW_PATHS) { + await navigateTo(page, path); + await expect(page).toHaveURL(new RegExp(path.replace(/\//g, "\\/"))); + await expect(navItem(page, "Agent Network")).toBeVisible(); + } + } 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 sidebar rendered (Peers is always present for an owner)... + await expect(navItem(page, "Peers")).toBeVisible(); + // ...and the Agent Network section is gone: the explicit opt-out wins + // and, without a deployment config flag, the section is not enabled. + 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(); + } + }); +}); diff --git a/e2e/tests/onboarding-form.spec.ts b/e2e/tests/onboarding-form.spec.ts new file mode 100644 index 00000000..cdcfb6ed --- /dev/null +++ b/e2e/tests/onboarding-form.spec.ts @@ -0,0 +1,169 @@ +/** + * Onboarding form selection spec. + * + * Validates which onboarding form is shown first and which one persists, + * across signup-source and account states — the recurring "regular form + * flashes before the Agent Network form" bug. Onboarding is disabled in the + * test build by default; each test opts in via the `netbird-test-onboarding` + * localStorage flag (see testOnboardingEnabled in src/utils/netbird.ts), and + * GET /accounts is rewritten to drive the account's onboarding + settings + * state (same interception approach as edition-gating.spec.ts). + */ +import { test, expect, type Browser, type Page } from "@playwright/test"; +import { loginToApp } from "../helpers/auth"; + +const SIGNUP_SOURCE_KEY = "netbird-signup-source"; +const AGENT_NETWORK_SOURCE = "netbird.ai"; + +const AGENT_FORM = "agent-network-onboarding"; +const REGULAR_FORM = "regular-onboarding"; + +type AccountState = { + signupFormPending?: boolean; + onboardingFlowPending?: boolean; + agentNetworkOnly?: boolean; +}; + +// mockAccounts rewrites GET /accounts to drive the onboarding + focused-view +// state, optionally delaying the response to emulate a slow backend. A PUT to +// /accounts/{id} flips agent_network_only to true afterwards, mirroring the +// server persisting the netbird.ai signup mark. +function mockAccounts(page: Page, state: AccountState, delayMs: number) { + let agentNetworkOnly = state.agentNetworkOnly; + + page.route("**/api/accounts", async (route) => { + if (route.request().method() !== "GET") return route.continue(); + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + 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].onboarding = { + ...(body[0].onboarding ?? {}), + signup_form_pending: !!state.signupFormPending, + onboarding_flow_pending: !!state.onboardingFlowPending, + }; + body[0].settings = { ...(body[0].settings ?? {}) }; + if (agentNetworkOnly === undefined) { + delete body[0].settings.agent_network_only; + } else { + body[0].settings.agent_network_only = agentNetworkOnly; + } + } + return route.fulfill({ response, json: body }); + }); + + page.route("**/api/accounts/*", async (route) => { + if (route.request().method() !== "PUT") return route.continue(); + agentNetworkOnly = true; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ settings: { agent_network_only: true } }), + }); + }); +} + +async function openOnboarding( + browser: Browser, + opts: { source?: boolean; account: AccountState; delayMs?: number }, +): Promise<{ page: Page; close: () => Promise }> { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/owner.json", + }); + await context.addInitScript( + ([sourceKey, sourceValue, withSource]) => { + try { + window.localStorage.setItem("netbird-test-onboarding", "true"); + if (withSource) { + window.localStorage.setItem( + sourceKey as string, + sourceValue as string, + ); + } + } catch (e) {} + }, + [SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE, !!opts.source] as const, + ); + const page = await context.newPage(); + mockAccounts(page, opts.account, opts.delayMs ?? 0); + await loginToApp(page, "owner", { expectOnboarding: true }); + return { page, close: () => context.close() }; +} + +test.describe.serial("Onboarding form selection @onboarding", () => { + test("netbird.ai signup shows the Agent Network form and never the regular one", async ({ + browser, + }) => { + const { page, close } = await openOnboarding(browser, { + source: true, + account: { signupFormPending: true, onboardingFlowPending: true }, + }); + try { + await expect(page.getByTestId(AGENT_FORM)).toBeVisible(); + await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0); + } finally { + await close(); + } + }); + + test("a plain new cloud account shows the regular onboarding form", async ({ + browser, + }) => { + const { page, close } = await openOnboarding(browser, { + source: false, + account: { signupFormPending: true, onboardingFlowPending: true }, + }); + try { + await expect(page.getByTestId(REGULAR_FORM)).toBeVisible(); + await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0); + } finally { + await close(); + } + }); + + test("an account already marked agent_network_only shows the Agent Network form", async ({ + browser, + }) => { + const { page, close } = await openOnboarding(browser, { + source: false, + account: { onboardingFlowPending: true, agentNetworkOnly: true }, + }); + try { + await expect(page.getByTestId(AGENT_FORM)).toBeVisible(); + await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0); + } finally { + await close(); + } + }); + + test("a slow backend never flashes the regular form for a netbird.ai signup", async ({ + browser, + }) => { + const { page, close } = await openOnboarding(browser, { + source: true, + account: { signupFormPending: true, onboardingFlowPending: true }, + delayMs: 1500, + }); + try { + // While /accounts is still loading, no onboarding form should have been + // committed to the wrong flow. + await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0); + // Once it resolves, the Agent Network form is the one that appears... + await expect(page.getByTestId(AGENT_FORM)).toBeVisible(); + await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0); + // ...and it persists after the setting is applied (no flip/close). + await page.waitForTimeout(1500); + await expect(page.getByTestId(AGENT_FORM)).toBeVisible(); + await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0); + } finally { + await close(); + } + }); +}); diff --git a/src/app/(dashboard)/agent-network/layout.tsx b/src/app/(dashboard)/agent-network/layout.tsx index 41eb9537..dd6571a2 100644 --- a/src/app/(dashboard)/agent-network/layout.tsx +++ b/src/app/(dashboard)/agent-network/layout.tsx @@ -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}; -} \ No newline at end of file + notFound(); +} diff --git a/src/app/(dashboard)/control-center/page.tsx b/src/app/(dashboard)/control-center/page.tsx index e69da442..e301036e 100644 --- a/src/app/(dashboard)/control-center/page.tsx +++ b/src/app/(dashboard)/control-center/page.tsx @@ -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, @@ -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"; @@ -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"); @@ -125,13 +127,13 @@ function ControlCenterView() { "/agent-network/providers", true, true, - isAgentNetworkEnabled(), + agentNetworkEnabled, ); const { data: agentPolicies } = useFetchApi( "/agent-network/policies", true, true, - isAgentNetworkEnabled(), + agentNetworkEnabled, ); // providerById lets the overlay look up a Provider's display payload @@ -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" && (
)} - {!isAgentNetworkOnly() && selectedNetwork && currentNetwork && ( + {!agentNetworkOnly && selectedNetwork && currentNetwork && ( )}
diff --git a/src/cloud/contexts/NetBirdCloudProvider.tsx b/src/cloud/contexts/NetBirdCloudProvider.tsx index bd936871..cc96efd8 100644 --- a/src/cloud/contexts/NetBirdCloudProvider.tsx +++ b/src/cloud/contexts/NetBirdCloudProvider.tsx @@ -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"; @@ -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 = () => { @@ -25,6 +32,9 @@ export const NetBirdCloudProvider = () => { "/integrations/billing/aws/marketplace/enrich", true, ).post; + const account = useAccount(); + const accountRequest = useApiCall("/accounts", true).put; + const signupSourceApplied = useRef(false); useEffect(() => { try { @@ -41,6 +51,47 @@ 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(async () => { + // Revalidate before clearing the source key so the persisted + // setting is in cache first. Clearing it earlier would leave a + // window where neither the source-pending optimism nor the stored + // setting holds, briefly flipping the focused view off and closing + // the onboarding form as the toast appears. + await mutate("/accounts"); + localStorage.removeItem(SIGNUP_SOURCE_LOCAL_STORAGE_KEY); + }), + loadingMessage: "Enabling Agent Network focused view...", + }); + } catch (e) {} + }, [account]); + const hasFreeOrTrialAWSPlan = subscription?.plan_tier === PlanTier.FREE || subscription?.plan_tier === PlanTier.TRIAL; diff --git a/src/hooks/useSignupSource.ts b/src/hooks/useSignupSource.ts new file mode 100644 index 00000000..902d2236 --- /dev/null +++ b/src/hooks/useSignupSource.ts @@ -0,0 +1,24 @@ +import { useEffect } from "react"; + +export const SIGNUP_SOURCE_QUERY_PARAM = "signup_source"; +export const SIGNUP_SOURCE_LOCAL_STORAGE_KEY = "netbird-signup-source"; +export const AGENT_NETWORK_SIGNUP_SOURCE = "netbird.ai"; + +/** + * Store the signup_source query parameter into localStorage so it survives + * the OIDC redirect and can be applied once the account is available. + */ +export function useSignupSource() { + useEffect(() => { + if (typeof window === "undefined") return; + + const params = new URLSearchParams(window.location.search); + const source = params.get(SIGNUP_SOURCE_QUERY_PARAM); + + if (source === AGENT_NETWORK_SIGNUP_SOURCE) { + try { + localStorage.setItem(SIGNUP_SOURCE_LOCAL_STORAGE_KEY, source); + } catch (e) {} + } + }, []); +} diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index 67184c09..38b96493 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -35,6 +35,7 @@ export interface Account { local_mfa_enabled?: boolean; ipv6_enabled_groups?: string[]; network_range_v6?: string; + agent_network_only?: boolean; }; onboarding?: AccountOnboarding; } diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index 392ad779..4f79c7d0 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -21,6 +21,7 @@ import ErrorBoundaryProvider from "@/contexts/ErrorBoundary"; import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider"; import InstanceSetupProvider from "@/contexts/InstanceSetupProvider"; import { NavigationEvents } from "@/contexts/NavigationEvents"; +import { useSignupSource } from "@/hooks/useSignupSource"; const inter = localFont({ src: "../assets/fonts/Inter.ttf", @@ -39,6 +40,7 @@ export default function AppLayout({ children, }: Readonly<{ children: React.ReactNode }>) { useAWSMarketplace(); + useSignupSource(); return ( diff --git a/src/layouts/Navigation.tsx b/src/layouts/Navigation.tsx index 79730040..8703ecc8 100644 --- a/src/layouts/Navigation.tsx +++ b/src/layouts/Navigation.tsx @@ -2,11 +2,7 @@ import { ScrollArea } from "@components/ScrollArea"; import { cn } from "@utils/helpers"; -import { - isAgentNetworkEnabled, - isAgentNetworkOnly, - isNetBirdCloud, -} from "@utils/netbird"; +import { isNetBirdCloud } from "@utils/netbird"; import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon"; import ControlCenterIcon from "@/assets/icons/ControlCenterIcon"; @@ -24,6 +20,7 @@ import { useAnnouncement } from "@/contexts/AnnouncementProvider"; import { useApplicationContext } from "@/contexts/ApplicationProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { headerHeight } from "@/layouts/Header"; +import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode"; import { NavigationUsageInfo } from "@/modules/billing/NavigationUsageInfo"; import { NetworkNavigation } from "@/modules/networks/misc/NetworkNavigation"; import { SmallBadge } from "@components/ui/SmallBadge"; @@ -43,6 +40,8 @@ export default function Navigation({ const { bannerHeight } = useAnnouncement(); const { isNavigationCollapsed } = useApplicationContext(); const { permission, isRestricted } = usePermissions(); + const { only: agentNetworkOnly, enabled: agentNetworkEnabled } = + useAgentNetworkMode(); return (
- {!isAgentNetworkOnly() && } + {!agentNetworkOnly && } } @@ -145,7 +144,7 @@ export default function Navigation({ href={"/reverse-proxy"} collapsible exactPathMatch={false} - visible={permission?.services?.read && !isAgentNetworkOnly()} + visible={permission?.services?.read && !agentNetworkOnly} > Agent Network - {!isAgentNetworkOnly() && ( + {!agentNetworkOnly && ( @@ -249,7 +240,7 @@ export default function Navigation({ exactPathMatch={true} visible={ (permission.dns.read || permission.nameservers.read) && - !isAgentNetworkOnly() + !agentNetworkOnly } > { const { permission } = usePermissions(); + const { only: agentNetworkOnly } = useAgentNetworkMode(); return ( { label="Activity" href={"/events"} collapsible - visible={permission.events.read && !isAgentNetworkOnly()} + visible={permission.events.read && !agentNetworkOnly} > ( "/agent-network/settings", true, true, - isAgentNetworkEnabled(), + agentNetworkEnabled, ); const settings = useMemo( () => (data ? settingsFromAPI(data) : null), @@ -528,7 +529,7 @@ export default function AIProvidersProvider({ children }: Readonly) { // disabled — it can safely wrap surfaces like the Control Center // without hitting agent-network endpoints in deployments that don't // have the feature. - const agentNetworkEnabled = isAgentNetworkEnabled(); + const { enabled: agentNetworkEnabled } = useAgentNetworkMode(); const { data: apiProviders, diff --git a/src/modules/agent-network/AgentOverviewPanel.tsx b/src/modules/agent-network/AgentOverviewPanel.tsx index 4510a4b9..45b2070b 100644 --- a/src/modules/agent-network/AgentOverviewPanel.tsx +++ b/src/modules/agent-network/AgentOverviewPanel.tsx @@ -16,7 +16,6 @@ import { Tooltip, } from "chart.js"; import useFetchApi from "@utils/api"; -import { isAgentNetworkEnabled } from "@utils/netbird"; import dayjs from "dayjs"; import { ActivityIcon, ExternalLinkIcon } from "lucide-react"; import * as React from "react"; @@ -30,6 +29,7 @@ import { APIAgentNetworkUsageBucket, buildUsageOverviewQuery, } from "@/modules/agent-network/agentAccessLogApi"; +import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode"; // Register the chart.js building blocks we use. Idempotent, so it's safe // even when another agent-network chart already registered them. @@ -55,6 +55,7 @@ export default function AgentOverviewPanel() { useAccessLogFilters(); const { groups } = useGroups(); const { users } = useUsers(); + const { enabled: agentNetworkEnabled } = useAgentNetworkMode(); // name → id and email → id maps so the (display-oriented) filter values // translate to the ids the backend filters on. @@ -82,7 +83,7 @@ export default function AgentOverviewPanel() { `/agent-network/usage/overview?${query}`, false, true, - isAgentNetworkEnabled(), + agentNetworkEnabled, ); const daily = useMemo(() => toDailyBuckets(buckets ?? []), [buckets]); diff --git a/src/modules/agent-network/useAgentNetworkMode.ts b/src/modules/agent-network/useAgentNetworkMode.ts new file mode 100644 index 00000000..cb93fdb2 --- /dev/null +++ b/src/modules/agent-network/useAgentNetworkMode.ts @@ -0,0 +1,61 @@ +import useFetchApi from "@utils/api"; +import { isAgentNetworkEnabled, isAgentNetworkOnly } from "@utils/netbird"; +import { useMemo } from "react"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { + AGENT_NETWORK_SIGNUP_SOURCE, + SIGNUP_SOURCE_LOCAL_STORAGE_KEY, +} from "@/hooks/useSignupSource"; +import { Account } from "@/interfaces/Account"; + +/** + * Report whether a new account arrived from the netbird.ai signup source and + * has not been marked yet. The focused view then applies immediately instead + * of flashing the regular onboarding while `agent_network_only` is persisted. + */ +const isAgentNetworkSignupPending = (account?: Account) => { + if (account?.onboarding?.signup_form_pending !== true) return false; + // Only apply optimism when the account has no explicit choice yet; an + // explicit true or false is the user's decision and must be respected. + if (account.settings?.agent_network_only !== undefined) return false; + try { + return ( + typeof window !== "undefined" && + localStorage.getItem(SIGNUP_SOURCE_LOCAL_STORAGE_KEY) === + AGENT_NETWORK_SIGNUP_SOURCE + ); + } catch (e) { + return false; + } +}; + +/** + * Resolve the Agent Network surface from the deployment config and the + * logged-in account settings. "only" hides the regular UI and focuses the + * dashboard on Agent Network, "enabled" makes the Agent Network surface + * available at all. "loading" is true while the account is still being + * fetched so guards can wait before redirecting. + */ +export const useAgentNetworkMode = () => { + const { permission } = usePermissions(); + + const { data: accounts, isLoading } = useFetchApi( + "/accounts", + true, + true, + permission.accounts.read, + ); + + return useMemo(() => { + const account = accounts?.[0]; + // An explicit account setting (true or false) always wins so a user can + // opt out even when the deployment config enables focused mode. Only when + // the setting is absent do signup optimism and deployment config apply. + const setting = account?.settings?.agent_network_only; + const only = + setting ?? (isAgentNetworkSignupPending(account) || isAgentNetworkOnly()); + const enabled = only || isAgentNetworkEnabled(); + const loading = permission.accounts.read ? isLoading : false; + return { only, enabled, loading } as const; + }, [accounts, isLoading, permission.accounts.read]); +}; diff --git a/src/modules/billing/trial/TrialOrUpgradeButton.tsx b/src/modules/billing/trial/TrialOrUpgradeButton.tsx index 79912bb2..c0b2b083 100644 --- a/src/modules/billing/trial/TrialOrUpgradeButton.tsx +++ b/src/modules/billing/trial/TrialOrUpgradeButton.tsx @@ -2,7 +2,7 @@ import Button from "@components/Button"; import FullTooltip from "@components/FullTooltip"; import { IconHelpCircle } from "@tabler/icons-react"; import { cn } from "@utils/helpers"; -import { isAgentNetworkOnly, isNetBirdCloud } from "@utils/netbird"; +import { isNetBirdCloud } from "@utils/netbird"; import { ExternalLinkIcon, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import * as React from "react"; @@ -11,6 +11,7 @@ import { PlanFeatures } from "@/cloud/cloud-hooks/useIsFeatureLocked"; import { useTrial } from "@/cloud/cloud-hooks/useTrial"; import { useLoggedInUser } from "@/contexts/UsersProvider"; import { PlanTier } from "@/interfaces/Subscription"; +import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode"; type Props = { plan?: PlanTier; @@ -143,10 +144,11 @@ export const SelfHostedUpgradeButton = ({ }: { variant?: "primary" | "white"; }) => { + const { only: agentNetworkOnly } = useAgentNetworkMode(); // Agent Network-only deployments point at the Agent Network pricing page // (tagged so the visit is attributable); the regular self-hosted product // keeps the on-prem pricing anchor. - const href = isAgentNetworkOnly() + const href = agentNetworkOnly ? "https://netbird.ai/pricing?utm_source=dashboard_oss" : "https://netbird.io/pricing#on-prem"; return ( diff --git a/src/modules/control-center/FlowSelector.tsx b/src/modules/control-center/FlowSelector.tsx index d623d132..02c3ed52 100644 --- a/src/modules/control-center/FlowSelector.tsx +++ b/src/modules/control-center/FlowSelector.tsx @@ -1,5 +1,4 @@ import { SegmentedTabs } from "@components/SegmentedTabs"; -import { isAgentNetworkOnly } from "@utils/netbird"; import { FolderGit2, MonitorSmartphoneIcon, @@ -7,6 +6,7 @@ import { UsersIcon, } from "lucide-react"; import * as React from "react"; +import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode"; export enum FlowView { NETWORKS = "networks", @@ -21,6 +21,8 @@ type Props = { }; export const FlowSelector = ({ value, onChange }: Props) => { + const { only: agentNetworkOnly } = useAgentNetworkMode(); + return ( onChange?.(v as FlowView)}> { {/* The agent-network repackaging drops Networks as a top-level pivot. Keep it for everyone else so flag-off behaviour is unchanged. */} - {!isAgentNetworkOnly() && ( + {!agentNetworkOnly && ( -
+
Onboarding diff --git a/src/modules/onboarding/OnboardingProvider.tsx b/src/modules/onboarding/OnboardingProvider.tsx index 48fcdcca..19c1906e 100644 --- a/src/modules/onboarding/OnboardingProvider.tsx +++ b/src/modules/onboarding/OnboardingProvider.tsx @@ -1,6 +1,10 @@ import { useLocalStorage } from "@hooks/useLocalStorage"; import useFetchApi, { useApiCall } from "@utils/api"; -import { isAgentNetworkOnly, isLocalDev, isNetBirdCloud } from "@utils/netbird"; +import { + isLocalDev, + isNetBirdCloud, + testOnboardingEnabled, +} from "@utils/netbird"; import { useRouter, useSearchParams } from "next/navigation"; import { useMemo } from "react"; import { useSWRConfig } from "swr"; @@ -10,7 +14,12 @@ import { useLoggedInUser } from "@/contexts/UsersProvider"; import { Account } from "@/interfaces/Account"; import { Network } from "@/interfaces/Network"; import type { Peer } from "@/interfaces/Peer"; +import { + AGENT_NETWORK_SIGNUP_SOURCE, + SIGNUP_SOURCE_LOCAL_STORAGE_KEY, +} from "@/hooks/useSignupSource"; import { useAccount } from "@/modules/account/useAccount"; +import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode"; import { AgentNetworkOnboarding } from "@/modules/onboarding/agent-network/AgentNetworkOnboarding"; import { Intent, @@ -18,6 +27,22 @@ import { OnboardingState, } from "@/modules/onboarding/Onboarding"; +// hasAgentNetworkSignupSource reads the netbird.ai signup source captured +// before authentication. It is available synchronously from the first render, +// so the onboarding can commit to the Agent Network form immediately instead +// of briefly showing the regular form while the account/mode data settles. +const hasAgentNetworkSignupSource = () => { + try { + return ( + typeof window !== "undefined" && + localStorage.getItem(SIGNUP_SOURCE_LOCAL_STORAGE_KEY) === + AGENT_NETWORK_SIGNUP_SOURCE + ); + } catch (e) { + return false; + } +}; + type Props = { onSurveySubmit?: (data: { fields: HubspotFormField[]; @@ -43,6 +68,8 @@ export const OnboardingProvider = ({ const params = useSearchParams(); const hsId = params?.get("hs_id") ?? ""; const gaId = params?.get("ga_id") ?? ""; + const { only: agentNetworkOnly, loading: agentNetworkModeLoading } = + useAgentNetworkMode(); const accountId = account?.id ?? "unknown"; const onboardingKey = `netbird-onboarding-flow:${accountId}`; @@ -66,21 +93,33 @@ export const OnboardingProvider = ({ }, ); + // A netbird.ai arrival commits to the Agent Network onboarding regardless of + // when the account setting is persisted; the signup source is known + // synchronously, so the regular form is never shown for these users. + const agentNetworkOnboarding = + agentNetworkOnly || hasAgentNetworkSignupSource(); + const showOnboarding = useMemo(() => { - if (process.env.APP_ENV === "test") return false; + if (process.env.APP_ENV === "test" && !testOnboardingEnabled()) { + return false; + } if (!account) return false; - // Agent Network-only deployments run a dedicated onboarding flow. The - // signup form is its first step (self-hosted only — the cloud survey relies - // on a JWT domain claim self-hosted IdPs don't emit), so the flow shows - // while either the signup form or the onboarding flow is still pending. - if (isAgentNetworkOnly()) { - const signupPending = - !isNetBirdCloud() && !!account?.onboarding?.signup_form_pending; + // The Agent Network onboarding runs a dedicated flow whose first step is + // the signup form. Unlike the regular cloud survey (which relies on a JWT + // domain claim), this form is shown on both cloud and self-hosted, so the + // flow stays visible while either the signup form or the onboarding flow + // is still pending. + if (agentNetworkOnboarding) { + const signupPending = !!account?.onboarding?.signup_form_pending; return ( isOwner && (signupPending || !!account?.onboarding?.onboarding_flow_pending) ); } + // For everyone else, wait until the Agent Network mode has resolved before + // deciding, so a slow mode fetch can't briefly show the regular form to an + // account that turns out to be Agent Network-only via config. + if (agentNetworkModeLoading) return false; if (!isNetBirdCloud()) return false; const isSignupFormPending = isNetBirdCloud() ? !!account?.onboarding?.signup_form_pending @@ -88,12 +127,11 @@ export const OnboardingProvider = ({ const show = !!account?.onboarding?.onboarding_flow_pending || isSignupFormPending; return isOwner && show; - }, [account, isOwner]); + }, [account, isOwner, agentNetworkOnboarding, agentNetworkModeLoading]); - // Self-hosted only: the cloud survey relies on a JWT domain claim self-hosted - // IdPs don't emit, so the agent-network flow uses its own signup step. - const agentSignupPending = - !isNetBirdCloud() && !!account?.onboarding?.signup_form_pending; + // The agent-network flow uses its own signup step on both cloud and + // self-hosted, so netbird.ai signups fill the form before onboarding. + const agentSignupPending = !!account?.onboarding?.signup_form_pending; const updateAccountMeta = async (meta: Partial) => { if (!account) return; @@ -219,7 +257,7 @@ export const OnboardingProvider = ({ ? !account?.onboarding?.signup_form_pending : true; - if (showOnboarding && isAgentNetworkOnly()) { + if (showOnboarding && agentNetworkOnboarding) { return ( -
+
Agent Network Onboarding @@ -123,7 +123,11 @@ export const AgentNetworkOnboarding = ({ > -
+
)} {step === STEP.PROVIDER && ( - + )} {step === STEP.POLICY && ( diff --git a/src/modules/settings/ClientSettingsTab.tsx b/src/modules/settings/ClientSettingsTab.tsx index 3fc12deb..6d71ab41 100644 --- a/src/modules/settings/ClientSettingsTab.tsx +++ b/src/modules/settings/ClientSettingsTab.tsx @@ -25,11 +25,13 @@ import { } from "lucide-react"; import React, { useMemo, useState } from "react"; import { useSWRConfig } from "swr"; +import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon"; import SettingsIcon from "@/assets/icons/SettingsIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { Account } from "@/interfaces/Account"; import { SmallBadge } from "@components/ui/SmallBadge"; import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; +import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode"; import useGroupHelper from "@/modules/groups/useGroupHelper"; import { useGroups } from "@/contexts/GroupsProvider"; import { SkeletonSettings } from "@components/skeletons/SkeletonSettings"; @@ -65,6 +67,7 @@ export default function ClientSettingsTab({ account }: Readonly) { function ClientSettingsTabContent({ account }: Readonly) { const { permission } = usePermissions(); + const { enabled: agentNetworkEnabled } = useAgentNetworkMode(); const { mutate } = useSWRConfig(); const saveRequest = useApiCall("/accounts/" + account.id, true); @@ -73,6 +76,10 @@ function ClientSettingsTabContent({ account }: Readonly) { account.settings?.lazy_connection_enabled ?? false, ); + const [agentNetworkOnly, setAgentNetworkOnly] = useState( + account.settings?.agent_network_only ?? false, + ); + const autoUpdateSetting = account.settings?.auto_update_version; const isAutoUpdateEnabled = !!autoUpdateSetting && autoUpdateSetting !== "disabled"; @@ -202,6 +209,28 @@ function ClientSettingsTabContent({ account }: Readonly) { }); }; + const toggleAgentNetworkOnly = async (toggle: boolean) => { + notify({ + title: "Agent Network Focused View", + description: `Agent Network focused view successfully ${ + toggle ? "enabled" : "disabled" + }.`, + promise: saveRequest + .put({ + id: account.id, + settings: { + ...account.settings, + agent_network_only: toggle, + }, + }) + .then(() => { + setAgentNetworkOnly(toggle); + mutate("/accounts"); + }), + loadingMessage: "Updating Agent Network focused view setting...", + }); + }; + return (
@@ -403,6 +432,31 @@ function ClientSettingsTabContent({ account }: Readonly) { disabled={!permission.settings.update} />
+ + {agentNetworkEnabled && ( +
+ + + Focus the dashboard on the Agent Network surface and hide + sections that are not relevant for it, such as Networks, DNS and + Reverse Proxy. + + +
+ )}
diff --git a/src/utils/netbird.ts b/src/utils/netbird.ts index aabcf3a8..0a5c7559 100644 --- a/src/utils/netbird.ts +++ b/src/utils/netbird.ts @@ -17,6 +17,19 @@ export const getInstallUrl = () => { export type Edition = "cloud" | "licensed" | "oss"; +// testOnboardingEnabled lets e2e tests opt into rendering the onboarding flow, +// which is disabled by default in the test build so it doesn't interfere with +// other specs. Inert outside test builds (the APP_ENV check is tree-shaken). +export const testOnboardingEnabled = (): boolean => { + if (process.env.APP_ENV !== "test") return false; + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem("netbird-test-onboarding") === "true"; + } catch (e) { + return false; + } +}; + // testEditionOverride lets e2e tests drive cloud/licensed/oss behavior against // the test build by setting localStorage. It is inert outside test builds, // where the APP_ENV check is replaced at compile time and tree-shaken away.