From 3cb76f71cdb6e73504ea055f12873204076b5e54 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 12:54:44 +0200 Subject: [PATCH 01/17] Capture netbird.ai signup source before authentication Store the source query parameter into localStorage on app load so it survives the OIDC redirect, and add the agent_network_only account setting to the Account interface. --- src/hooks/useSignupSource.ts | 23 +++++++++++++++++++++++ src/interfaces/Account.ts | 1 + src/layouts/AppLayout.tsx | 2 ++ 3 files changed, 26 insertions(+) create mode 100644 src/hooks/useSignupSource.ts diff --git a/src/hooks/useSignupSource.ts b/src/hooks/useSignupSource.ts new file mode 100644 index 00000000..68b6f29c --- /dev/null +++ b/src/hooks/useSignupSource.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; + +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("source"); + + 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 ( From b62a1840c117da086b07e4b817b6aeee0541a51f Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 12:55:02 +0200 Subject: [PATCH 02/17] Resolve Agent Network mode from account settings Add useAgentNetworkMode, which combines the deployment config flags with the account-level agent_network_only setting, and swap the UI gating call sites (navigation, route guard, control center, flow selector, providers provider, usage overview) to it. The agent-network route guard now waits for the account to load before calling notFound so accounts enabled via settings don't get a redirect flash. --- src/app/(dashboard)/agent-network/layout.tsx | 21 ++++++---- src/app/(dashboard)/control-center/page.tsx | 12 +++--- src/layouts/Navigation.tsx | 40 ++++++++----------- .../agent-network/AIProvidersProvider.tsx | 7 ++-- .../agent-network/AgentOverviewPanel.tsx | 5 ++- .../agent-network/useAgentNetworkMode.ts | 32 +++++++++++++++ src/modules/control-center/FlowSelector.tsx | 6 ++- 7 files changed, 80 insertions(+), 43 deletions(-) create mode 100644 src/modules/agent-network/useAgentNetworkMode.ts 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/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..d254b191 --- /dev/null +++ b/src/modules/agent-network/useAgentNetworkMode.ts @@ -0,0 +1,32 @@ +import useFetchApi from "@utils/api"; +import { isAgentNetworkEnabled, isAgentNetworkOnly } from "@utils/netbird"; +import { useMemo } from "react"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { Account } from "@/interfaces/Account"; + +/** + * 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]; + const only = + account?.settings?.agent_network_only === true || 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/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 && ( Date: Sun, 12 Jul 2026 12:55:16 +0200 Subject: [PATCH 03/17] Enable Agent Network focused view for netbird.ai signups Once the logged-in account is available, apply the stored netbird.ai signup source: new accounts (signup form still pending) are updated with agent_network_only via PUT /accounts/{id}, while stale flags from existing accounts are discarded. The flag is only cleared after a successful write so failures retry on the next session. --- src/cloud/contexts/NetBirdCloudProvider.tsx | 48 ++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/cloud/contexts/NetBirdCloudProvider.tsx b/src/cloud/contexts/NetBirdCloudProvider.tsx index bd936871..bfd1f601 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,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]); + const hasFreeOrTrialAWSPlan = subscription?.plan_tier === PlanTier.FREE || subscription?.plan_tier === PlanTier.TRIAL; From d4217db9a8ce2b22803f69f28fca57e15ac77243 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 12:55:32 +0200 Subject: [PATCH 04/17] Add Agent Network focused view toggle to client settings Expose the agent_network_only account setting in the Clients settings tab so users can leave (or re-enter) the focused view. Shown when the Agent Network surface is available for the account or deployment. --- src/modules/settings/ClientSettingsTab.tsx | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) 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. + + +
+ )}
From 7dd95af94aacbab8fc71a1d26380b5e6286212b1 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 13:10:49 +0200 Subject: [PATCH 05/17] Swap remaining agent network gating call sites --- src/modules/billing/trial/TrialOrUpgradeButton.tsx | 6 ++++-- src/modules/onboarding/OnboardingProvider.tsx | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) 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/onboarding/OnboardingProvider.tsx b/src/modules/onboarding/OnboardingProvider.tsx index 48fcdcca..ef57964e 100644 --- a/src/modules/onboarding/OnboardingProvider.tsx +++ b/src/modules/onboarding/OnboardingProvider.tsx @@ -1,6 +1,6 @@ import { useLocalStorage } from "@hooks/useLocalStorage"; import useFetchApi, { useApiCall } from "@utils/api"; -import { isAgentNetworkOnly, isLocalDev, isNetBirdCloud } from "@utils/netbird"; +import { isLocalDev, isNetBirdCloud } from "@utils/netbird"; import { useRouter, useSearchParams } from "next/navigation"; import { useMemo } from "react"; import { useSWRConfig } from "swr"; @@ -11,6 +11,7 @@ import { Account } from "@/interfaces/Account"; import { Network } from "@/interfaces/Network"; import type { Peer } from "@/interfaces/Peer"; import { useAccount } from "@/modules/account/useAccount"; +import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode"; import { AgentNetworkOnboarding } from "@/modules/onboarding/agent-network/AgentNetworkOnboarding"; import { Intent, @@ -43,6 +44,7 @@ export const OnboardingProvider = ({ const params = useSearchParams(); const hsId = params?.get("hs_id") ?? ""; const gaId = params?.get("ga_id") ?? ""; + const { only: agentNetworkOnly } = useAgentNetworkMode(); const accountId = account?.id ?? "unknown"; const onboardingKey = `netbird-onboarding-flow:${accountId}`; @@ -73,7 +75,7 @@ export const OnboardingProvider = ({ // 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()) { + if (agentNetworkOnly) { const signupPending = !isNetBirdCloud() && !!account?.onboarding?.signup_form_pending; return ( @@ -88,7 +90,7 @@ export const OnboardingProvider = ({ const show = !!account?.onboarding?.onboarding_flow_pending || isSignupFormPending; return isOwner && show; - }, [account, isOwner]); + }, [account, isOwner, agentNetworkOnly]); // 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. @@ -219,7 +221,7 @@ export const OnboardingProvider = ({ ? !account?.onboarding?.signup_form_pending : true; - if (showOnboarding && isAgentNetworkOnly()) { + if (showOnboarding && agentNetworkOnly) { return ( Date: Sun, 12 Jul 2026 13:13:41 +0200 Subject: [PATCH 06/17] Use signup_source query parameter for signup source capture --- src/hooks/useSignupSource.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSignupSource.ts b/src/hooks/useSignupSource.ts index 68b6f29c..902d2236 100644 --- a/src/hooks/useSignupSource.ts +++ b/src/hooks/useSignupSource.ts @@ -1,10 +1,11 @@ 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 + * 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() { @@ -12,7 +13,7 @@ export function useSignupSource() { if (typeof window === "undefined") return; const params = new URLSearchParams(window.location.search); - const source = params.get("source"); + const source = params.get(SIGNUP_SOURCE_QUERY_PARAM); if (source === AGENT_NETWORK_SIGNUP_SOURCE) { try { From b2808489660dec86aa43b98520443807961980ae Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 14:23:15 +0200 Subject: [PATCH 07/17] Resolve Agent Network mode optimistically for pending netbird.ai signups Prevents the regular onboarding from flashing before agent_network_only is persisted: while a new account still carries the netbird.ai signup source and is unmarked, the focused view applies immediately. Gated on signup_form_pending so an existing user's stale source flag never flips the view. --- .../agent-network/useAgentNetworkMode.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/modules/agent-network/useAgentNetworkMode.ts b/src/modules/agent-network/useAgentNetworkMode.ts index d254b191..d6bb37a8 100644 --- a/src/modules/agent-network/useAgentNetworkMode.ts +++ b/src/modules/agent-network/useAgentNetworkMode.ts @@ -2,8 +2,31 @@ 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; + if (account.settings?.agent_network_only === true) 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 @@ -24,7 +47,9 @@ export const useAgentNetworkMode = () => { return useMemo(() => { const account = accounts?.[0]; const only = - account?.settings?.agent_network_only === true || isAgentNetworkOnly(); + isAgentNetworkSignupPending(account) || + account?.settings?.agent_network_only === true || + isAgentNetworkOnly(); const enabled = only || isAgentNetworkEnabled(); const loading = permission.accounts.read ? isLoading : false; return { only, enabled, loading } as const; From d16672fe92f214954d578d6010598bba1ad8cd99 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 14:54:53 +0200 Subject: [PATCH 08/17] Respect explicit agent_network_only opt-out over deployment config Use nullish fallback so an explicit account setting (true or false) always wins: a user can disable the focused view even when the deployment config enables it, and the signup-pending optimism only applies when the setting is absent, never overriding an explicit false. --- src/modules/agent-network/useAgentNetworkMode.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/modules/agent-network/useAgentNetworkMode.ts b/src/modules/agent-network/useAgentNetworkMode.ts index d6bb37a8..cb93fdb2 100644 --- a/src/modules/agent-network/useAgentNetworkMode.ts +++ b/src/modules/agent-network/useAgentNetworkMode.ts @@ -15,7 +15,9 @@ import { Account } from "@/interfaces/Account"; */ const isAgentNetworkSignupPending = (account?: Account) => { if (account?.onboarding?.signup_form_pending !== true) return false; - if (account.settings?.agent_network_only === 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" && @@ -46,10 +48,12 @@ export const useAgentNetworkMode = () => { 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 = - isAgentNetworkSignupPending(account) || - account?.settings?.agent_network_only === true || - isAgentNetworkOnly(); + setting ?? (isAgentNetworkSignupPending(account) || isAgentNetworkOnly()); const enabled = only || isAgentNetworkEnabled(); const loading = permission.accounts.read ? isLoading : false; return { only, enabled, loading } as const; From cc7b811b4bde2d19e7bce9e05d0503d9e1c1cca2 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 15:15:13 +0200 Subject: [PATCH 09/17] Add e2e spec for Agent Network focused view menu and routes --- e2e/tests/agent-network-focused-view.spec.ts | 222 +++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 e2e/tests/agent-network-focused-view.spec.ts 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..31c77a9f --- /dev/null +++ b/e2e/tests/agent-network-focused-view.spec.ts @@ -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; +}> { + 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(); + } + // ...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(); + } + }); +}); From 8920b2d8a09d1f8da6ea7045b8e4a94414f98283 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 15:27:34 +0200 Subject: [PATCH 10/17] Anchor focused-view e2e assertions on always-present nav to avoid backend permission gaps --- e2e/tests/agent-network-focused-view.spec.ts | 22 +++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/e2e/tests/agent-network-focused-view.spec.ts b/e2e/tests/agent-network-focused-view.spec.ts index 31c77a9f..b194af89 100644 --- a/e2e/tests/agent-network-focused-view.spec.ts +++ b/e2e/tests/agent-network-focused-view.spec.ts @@ -110,8 +110,9 @@ function navItem(page: Page, text: string) { .getByText(text, { exact: true }); } -// Regular dashboard sections that the focused view hides. -const REGULAR_NAV = ["Networks", "Reverse Proxy", "DNS", "Activity"]; +// 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 views that make up the focused menu. const AGENT_NAV_CHILDREN = [ "Providers", @@ -128,13 +129,15 @@ test.describe.serial("Agent Network focused view @agent-network", () => { 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 REGULAR_NAV) { + for (const label of FOCUS_HIDDEN_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(); @@ -170,11 +173,10 @@ test.describe.serial("Agent Network focused view @agent-network", () => { agentNetworkOnly: false, }); try { - // The regular sections come back... - for (const label of REGULAR_NAV) { - await expect(navItem(page, label)).toBeVisible(); - } - // ...and the Agent Network section is gone (not enabled by config here). + // 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(); From 38dc2aa6c00aef291f0607057b4727deaf695ae3 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 15:39:03 +0200 Subject: [PATCH 11/17] Deflake focused-view e2e: assert route reachability instead of sidebar expansion --- e2e/tests/agent-network-focused-view.spec.ts | 32 +++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/e2e/tests/agent-network-focused-view.spec.ts b/e2e/tests/agent-network-focused-view.spec.ts index b194af89..2a0d559c 100644 --- a/e2e/tests/agent-network-focused-view.spec.ts +++ b/e2e/tests/agent-network-focused-view.spec.ts @@ -113,12 +113,12 @@ function navItem(page: Page, text: string) { // 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 views that make up the focused menu. -const AGENT_NAV_CHILDREN = [ - "Providers", - "Policies", - "Usage & Logs", - "Configuration", +// 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", () => { @@ -138,29 +138,25 @@ test.describe.serial("Agent Network focused view @agent-network", () => { for (const label of FOCUS_HIDDEN_NAV) { await expect(navItem(page, label)).toHaveCount(0); } - - // 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 ({ + test("focused view keeps every Agent Network route 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); + // 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(); } From 2aa59cae94d4d9ddfe8bb35e2dede9bd5208d71e Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 16:35:45 +0200 Subject: [PATCH 12/17] Fix onboarding form closing when Agent Network focused view is applied Await the /accounts revalidation before clearing the netbird.ai signup source key. Clearing it first left a window where neither the source-pending optimism nor the persisted agent_network_only setting held, so the focused view flipped off for a beat and tore down the onboarding form exactly as the enablement toast appeared. --- src/cloud/contexts/NetBirdCloudProvider.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/cloud/contexts/NetBirdCloudProvider.tsx b/src/cloud/contexts/NetBirdCloudProvider.tsx index bfd1f601..cc96efd8 100644 --- a/src/cloud/contexts/NetBirdCloudProvider.tsx +++ b/src/cloud/contexts/NetBirdCloudProvider.tsx @@ -78,8 +78,13 @@ export const NetBirdCloudProvider = () => { settings: { ...account.settings, agent_network_only: true }, }, "/" + account.id, - ).then(() => { - mutate("/accounts"); + ).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...", From 7ad5be9c29f95fa9cd37df1549f5b7e2072e3c3c Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 17:02:08 +0200 Subject: [PATCH 13/17] Show the agent-network signup form on cloud for netbird.ai signups The agent-network onboarding signup-form step was gated self-hosted-only, so cloud netbird.ai signups skipped it. Drive it off signup_form_pending on both cloud and self-hosted so those users fill the form and then continue through the agent-network onboarding flow. --- src/modules/onboarding/OnboardingProvider.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/modules/onboarding/OnboardingProvider.tsx b/src/modules/onboarding/OnboardingProvider.tsx index ef57964e..1b3014d0 100644 --- a/src/modules/onboarding/OnboardingProvider.tsx +++ b/src/modules/onboarding/OnboardingProvider.tsx @@ -71,13 +71,13 @@ export const OnboardingProvider = ({ const showOnboarding = useMemo(() => { if (process.env.APP_ENV === "test") 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. + // The Agent Network focused view runs a dedicated onboarding 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 (agentNetworkOnly) { - const signupPending = - !isNetBirdCloud() && !!account?.onboarding?.signup_form_pending; + const signupPending = !!account?.onboarding?.signup_form_pending; return ( isOwner && (signupPending || !!account?.onboarding?.onboarding_flow_pending) @@ -92,10 +92,9 @@ export const OnboardingProvider = ({ return isOwner && show; }, [account, isOwner, agentNetworkOnly]); - // 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; From dd35a33284cd50a27fb181d6e6b4c76ac4721160 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 17:38:49 +0200 Subject: [PATCH 14/17] Defer onboarding until Agent Network mode resolves to avoid wrong-form flash account can load before useAgentNetworkMode's own /accounts fetch settles, which briefly rendered the regular onboarding form and then switched to the Agent Network flow. Hold onboarding while the mode is loading so the user only ever sees the correct form. --- src/modules/onboarding/OnboardingProvider.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/modules/onboarding/OnboardingProvider.tsx b/src/modules/onboarding/OnboardingProvider.tsx index 1b3014d0..0bba6088 100644 --- a/src/modules/onboarding/OnboardingProvider.tsx +++ b/src/modules/onboarding/OnboardingProvider.tsx @@ -44,7 +44,8 @@ export const OnboardingProvider = ({ const params = useSearchParams(); const hsId = params?.get("hs_id") ?? ""; const gaId = params?.get("ga_id") ?? ""; - const { only: agentNetworkOnly } = useAgentNetworkMode(); + const { only: agentNetworkOnly, loading: agentNetworkModeLoading } = + useAgentNetworkMode(); const accountId = account?.id ?? "unknown"; const onboardingKey = `netbird-onboarding-flow:${accountId}`; @@ -71,6 +72,11 @@ export const OnboardingProvider = ({ const showOnboarding = useMemo(() => { if (process.env.APP_ENV === "test") return false; if (!account) return false; + // Wait until the Agent Network mode is resolved before showing any form. + // account can load before useAgentNetworkMode's own /accounts fetch + // settles; rendering during that window would show the regular onboarding + // and then switch to the Agent Network flow once the mode arrives. + if (agentNetworkModeLoading) return false; // The Agent Network focused view runs a dedicated onboarding 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 @@ -90,7 +96,7 @@ export const OnboardingProvider = ({ const show = !!account?.onboarding?.onboarding_flow_pending || isSignupFormPending; return isOwner && show; - }, [account, isOwner, agentNetworkOnly]); + }, [account, isOwner, agentNetworkOnly, agentNetworkModeLoading]); // The agent-network flow uses its own signup step on both cloud and // self-hosted, so netbird.ai signups fill the form before onboarding. From 0fff98c3a370ea94a7779188ccebaa3934bad740 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 18:04:26 +0200 Subject: [PATCH 15/17] Decide onboarding form from signup_source so the regular form never flashes Reading the netbird.ai signup source synchronously lets onboarding commit to the Agent Network form on the first render, independent of when the account setting or agent-network mode data settles. The regular form is no longer shown to netbird.ai arrivals while the backend catches up. --- src/modules/onboarding/OnboardingProvider.tsx | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/src/modules/onboarding/OnboardingProvider.tsx b/src/modules/onboarding/OnboardingProvider.tsx index 0bba6088..e69dcd60 100644 --- a/src/modules/onboarding/OnboardingProvider.tsx +++ b/src/modules/onboarding/OnboardingProvider.tsx @@ -10,6 +10,10 @@ 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"; @@ -19,6 +23,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[]; @@ -69,26 +89,31 @@ 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 (!account) return false; - // Wait until the Agent Network mode is resolved before showing any form. - // account can load before useAgentNetworkMode's own /accounts fetch - // settles; rendering during that window would show the regular onboarding - // and then switch to the Agent Network flow once the mode arrives. - if (agentNetworkModeLoading) return false; - // The Agent Network focused view runs a dedicated onboarding 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 (agentNetworkOnly) { + // 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 @@ -96,7 +121,7 @@ export const OnboardingProvider = ({ const show = !!account?.onboarding?.onboarding_flow_pending || isSignupFormPending; return isOwner && show; - }, [account, isOwner, agentNetworkOnly, agentNetworkModeLoading]); + }, [account, isOwner, agentNetworkOnboarding, agentNetworkModeLoading]); // The agent-network flow uses its own signup step on both cloud and // self-hosted, so netbird.ai signups fill the form before onboarding. @@ -226,7 +251,7 @@ export const OnboardingProvider = ({ ? !account?.onboarding?.signup_form_pending : true; - if (showOnboarding && agentNetworkOnly) { + if (showOnboarding && agentNetworkOnboarding) { return ( Date: Sun, 12 Jul 2026 18:16:20 +0200 Subject: [PATCH 16/17] Add e2e coverage for onboarding form selection Adds a test-only override to render onboarding in the test build, testids on both onboarding forms, and a spec validating which form shows first and which persists: netbird.ai signup shows the Agent Network form (never the regular one, even under a slow backend), a plain cloud account shows the regular form, and an agent_network_only account shows the Agent Network form. --- e2e/tests/onboarding-form.spec.ts | 169 ++++++++++++++++++ src/modules/onboarding/Onboarding.tsx | 2 +- src/modules/onboarding/OnboardingProvider.tsx | 10 +- .../agent-network/AgentNetworkOnboarding.tsx | 13 +- src/utils/netbird.ts | 13 ++ 5 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 e2e/tests/onboarding-form.spec.ts diff --git a/e2e/tests/onboarding-form.spec.ts b/e2e/tests/onboarding-form.spec.ts new file mode 100644 index 00000000..6039940e --- /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"); + 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/modules/onboarding/Onboarding.tsx b/src/modules/onboarding/Onboarding.tsx index 9d0bc83c..19299718 100644 --- a/src/modules/onboarding/Onboarding.tsx +++ b/src/modules/onboarding/Onboarding.tsx @@ -306,7 +306,7 @@ export const Onboarding = ({ "h-full w-screen fixed z-[50] left-0 top-0 bg-nb-gray-950 flex overflow-y-auto" } > -
+
Onboarding diff --git a/src/modules/onboarding/OnboardingProvider.tsx b/src/modules/onboarding/OnboardingProvider.tsx index e69dcd60..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 { 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"; @@ -96,7 +100,9 @@ export const OnboardingProvider = ({ 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; // 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 diff --git a/src/modules/onboarding/agent-network/AgentNetworkOnboarding.tsx b/src/modules/onboarding/agent-network/AgentNetworkOnboarding.tsx index fb836179..fe28d05b 100644 --- a/src/modules/onboarding/agent-network/AgentNetworkOnboarding.tsx +++ b/src/modules/onboarding/agent-network/AgentNetworkOnboarding.tsx @@ -112,7 +112,7 @@ export const AgentNetworkOnboarding = ({ "h-full w-screen fixed z-[50] left-0 top-0 bg-nb-gray-950 flex overflow-y-auto" } > -
+
Agent Network Onboarding @@ -123,7 +123,11 @@ export const AgentNetworkOnboarding = ({ > -
+
)} {step === STEP.PROVIDER && ( - + )} {step === STEP.POLICY && ( 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. From 0ef296f9b8226bd49c60c4d88ea61c1b8826bbd1 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 18:27:28 +0200 Subject: [PATCH 17/17] Let loginToApp expect an onboarding overlay instead of dismissing the setup modal --- e2e/helpers/auth.ts | 21 +++++++++++++++++---- e2e/tests/onboarding-form.spec.ts | 2 +- 2 files changed, 18 insertions(+), 5 deletions(-) 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/onboarding-form.spec.ts b/e2e/tests/onboarding-form.spec.ts index 6039940e..cdcfb6ed 100644 --- a/e2e/tests/onboarding-form.spec.ts +++ b/e2e/tests/onboarding-form.spec.ts @@ -93,7 +93,7 @@ async function openOnboarding( ); const page = await context.newPage(); mockAccounts(page, opts.account, opts.delayMs ?? 0); - await loginToApp(page, "owner"); + await loginToApp(page, "owner", { expectOnboarding: true }); return { page, close: () => context.close() }; }