diff --git a/e2e/helpers/api.ts b/e2e/helpers/api.ts index c8bcc8c4e..a92d684dd 100644 --- a/e2e/helpers/api.ts +++ b/e2e/helpers/api.ts @@ -46,8 +46,7 @@ async function getApiContext( ]); const request = response.request(); - const authHeader = - (await request.allHeaders())["authorization"] || ""; + const authHeader = (await request.allHeaders())["authorization"] || ""; const token = authHeader.replace("Bearer ", ""); const url = new URL(request.url()); const origin = `${url.protocol}//${url.host}`; @@ -76,15 +75,35 @@ async function apiDelete(page: Page, path: string): Promise { }); } -async function apiPost(page: Page, path: string, data: unknown): Promise { +async function apiPost(page: Page, path: string, body: unknown): Promise { const { token, origin } = await getApiContext(page); const resp = await page.request.post(`${origin}/api${path}`, { headers: { Authorization: `Bearer ${token}` }, - data, + data: body, }); + if (!resp.ok()) { + throw new Error( + `POST ${path} returned ${resp.status()}: ${await resp.text()}`, + ); + } return resp.json(); } +async function apiPut(page: Page, path: string, body: unknown): Promise { + const { token, origin } = await getApiContext(page); + const resp = await page.request.put(`${origin}/api${path}`, { + headers: { Authorization: `Bearer ${token}` }, + data: body, + }); + if (!resp.ok()) { + throw new Error( + `PUT ${path} returned ${resp.status()}: ${await resp.text()}`, + ); + } + return resp.json(); +} + +/** List all groups. */ export async function listGroups(page: Page): Promise { return apiGet(page, "/groups"); } @@ -108,10 +127,12 @@ export async function deletePeersByPrefix(page: Page, prefix: string) { } } +/** Create a group by name. */ export async function createGroup(page: Page, name: string): Promise { return apiPost(page, "/groups", { name, peers: [] }); } +/** Delete a group by ID. */ export async function deleteGroup(page: Page, groupId: string) { await apiDelete(page, `/groups/${groupId}`); } @@ -259,7 +280,10 @@ export async function deleteRouteById(page: Page, routeId: string) { await apiDelete(page, `/routes/${routeId}`); } -export async function deleteRoutesByNetworkIdPrefix(page: Page, prefix: string) { +export async function deleteRoutesByNetworkIdPrefix( + page: Page, + prefix: string, +) { const routes = await listRoutes(page); const toDelete = routes.filter((r) => r.network_id.startsWith(prefix)); for (const r of toDelete) { @@ -338,8 +362,13 @@ type NotificationChannel = { enabled: boolean; }; -export async function listNotificationChannels(page: Page): Promise { - return apiGet(page, "/integrations/notifications/channels"); +export async function listNotificationChannels( + page: Page, +): Promise { + return apiGet( + page, + "/integrations/notifications/channels", + ); } export async function deleteNotificationChannel(page: Page, channelId: string) { @@ -353,7 +382,10 @@ export async function deleteAllNotificationChannels(page: Page) { } } -export async function deleteNotificationChannelsByType(page: Page, type: string) { +export async function deleteNotificationChannelsByType( + page: Page, + type: string, +) { const channels = await listNotificationChannels(page); const toDelete = channels.filter((c) => c.type === type); for (const c of toDelete) { @@ -368,7 +400,9 @@ type NameserverGroup = { name: string; }; -export async function listNameserverGroups(page: Page): Promise { +export async function listNameserverGroups( + page: Page, +): Promise { return apiGet(page, "/dns/nameservers"); } @@ -376,7 +410,10 @@ export async function deleteNameserverGroupById(page: Page, id: string) { await apiDelete(page, `/dns/nameservers/${id}`); } -export async function deleteNameserverGroupsByPrefix(page: Page, prefix: string) { +export async function deleteNameserverGroupsByPrefix( + page: Page, + prefix: string, +) { const groups = await listNameserverGroups(page); const toDelete = groups.filter((g) => g.name.startsWith(prefix)); for (const g of toDelete) { @@ -391,11 +428,16 @@ type ReverseProxyService = { name: string; }; -export async function listReverseProxyServices(page: Page): Promise { +export async function listReverseProxyServices( + page: Page, +): Promise { return apiGet(page, "/reverse-proxies/services"); } -export async function deleteReverseProxyServiceById(page: Page, serviceId: string) { +export async function deleteReverseProxyServiceById( + page: Page, + serviceId: string, +) { await apiDelete(page, `/reverse-proxies/services/${serviceId}`); } @@ -449,7 +491,13 @@ export async function waitForProxyClustersOnline( throw new Error( `Proxy clusters not online after ${timeoutMs}ms. Expected ${addresses.join( ", ", - )}; got ${JSON.stringify(last.map((c) => ({ a: c.address, online: c.online, n: c.connected_proxies })))}`, + )}; got ${JSON.stringify( + last.map((c) => ({ + a: c.address, + online: c.online, + n: c.connected_proxies, + })), + )}`, ); } @@ -461,6 +509,8 @@ type User = { name: string; role: string; status: string; + auto_groups: string[]; + is_blocked: boolean; is_current: boolean; }; @@ -538,3 +588,85 @@ export async function supportsAgentNetworkSettingsBootstrap( !!settings && typeof settings === "object" && "proxy_address" in settings ); } + +/** + * Whether the management build under test serves the caller-scoped + * GET /agent-network/agent-config answer that backs the Connect Agent page. + * The endpoint answers 200 for every authenticated caller (an unconfigured + * caller gets configured=false, never an error), so any non-OK status means + * the build predates it. + */ +export async function supportsAgentNetworkAgentConfig( + page: Page, +): Promise { + const { token, origin } = await getApiContext(page); + const resp = await page.request.get( + `${origin}/api/agent-network/agent-config`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + return resp.ok(); +} + +type AgentNetworkPolicy = { + id: string; + name: string; +}; + +/** List Agent Network policies. */ +export async function listAgentNetworkPolicies( + page: Page, +): Promise { + return apiGet(page, "/agent-network/policies"); +} + +/** Create an Agent Network policy. */ +export async function createAgentNetworkPolicy( + page: Page, + body: { + name: string; + source_groups: string[]; + destination_provider_ids: string[]; + enabled?: boolean; + }, +): Promise { + return apiPost(page, "/agent-network/policies", { + enabled: true, + ...body, + }); +} + +/** Delete all Agent Network policies whose name starts with the prefix. */ +export async function deleteAgentNetworkPoliciesByPrefix( + page: Page, + prefix: string, +) { + const policies = await listAgentNetworkPolicies(page); + for (const p of policies) { + if (p.name.startsWith(prefix)) { + await apiDelete(page, `/agent-network/policies/${p.id}`); + } + } +} + +/** The user the captured token belongs to. */ +export async function getCurrentUser(page: Page): Promise { + const users = await apiGet(page, "/users"); + const current = users.find((u) => u.is_current); + if (!current) { + throw new Error("no is_current user in the /users answer"); + } + return current; +} + +/** Replace a user's auto-groups, keeping role and blocked state. */ +export async function updateUserAutoGroups( + page: Page, + user: User, + autoGroups: string[], +): Promise { + await apiPut(page, `/users/${user.id}`, { + role: user.role, + auto_groups: autoGroups, + is_blocked: !!user.is_blocked, + }); +} diff --git a/e2e/tests/agent-network-kimi-provider.spec.ts b/e2e/tests/agent-network-kimi-provider.spec.ts index 31be3d76f..19dca5516 100644 --- a/e2e/tests/agent-network-kimi-provider.spec.ts +++ b/e2e/tests/agent-network-kimi-provider.spec.ts @@ -3,9 +3,9 @@ * * Walks the Kimi provider lifecycle end to end against the real backend: * pick kimi_api from the catalog (prefilled host, catalog models with - * pricing), connect it, then verify the Kimi-gated config surfaces in the - * "Configure Your Agent" modal (Kimi CLI tab, Kimi backend option in the - * Claude Code tab) that only render when a Kimi provider is connected. + * pricing), connect it, then verify the Kimi-gated config surfaces on the + * Connect Agent page (Kimi CLI tab, Kimi backend option in the Claude Code + * tab) that only render when a Kimi provider is connected. * * The kimi_api catalog entry ships with newer management builds. When the * backend under test predates it, the whole suite skips instead of failing — @@ -19,20 +19,57 @@ * localStorage override (see testAgentNetworkOverride in utils/netbird.ts), * set via addInitScript on a dedicated context below. */ -import { test, expect, type Browser, type Page } from "@playwright/test"; -import { loginToApp } from "../helpers/auth"; -import { generateRandomName } from "../helpers/utils"; +import { type Browser, expect, type Page,test } from "@playwright/test"; import { + createAgentNetworkPolicy, + createGroup, + deleteAgentNetworkPoliciesByPrefix, deleteAgentNetworkProvidersByPrefix, + deleteGroup, + getCurrentUser, listAgentNetworkCatalog, + listGroups, + supportsAgentNetworkAgentConfig, supportsAgentNetworkSettingsBootstrap, + updateUserAutoGroups, } from "../helpers/api"; +import { loginToApp } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network"; const KIMI_CATALOG_ID = "kimi_api"; const KIMI_CATALOG_NAME = "Kimi (Moonshot AI) API"; const PROVIDER_PREFIX = "e2e-kimi-"; +/** + * Remove every fixture this spec creates: the policy granting the caller the + * provider, the group carrying that grant (detached from the caller first — + * a group referenced by a user's auto-groups refuses deletion), and the + * provider itself. Runs before the test too, so leftovers from an interrupted + * run never poison the next one. + */ +async function cleanupKimiFixtures(page: Page) { + await deleteAgentNetworkPoliciesByPrefix(page, PROVIDER_PREFIX); + const fixtureGroups = (await listGroups(page)).filter((g) => + g.name.startsWith(PROVIDER_PREFIX), + ); + if (fixtureGroups.length > 0) { + const fixtureGroupIds = new Set(fixtureGroups.map((g) => g.id)); + const caller = await getCurrentUser(page); + if ((caller.auto_groups ?? []).some((id) => fixtureGroupIds.has(id))) { + await updateUserAutoGroups( + page, + caller, + (caller.auto_groups ?? []).filter((id) => !fixtureGroupIds.has(id)), + ); + } + for (const g of fixtureGroups) { + await deleteGroup(page, g.id); + } + } + await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX); +} + async function newAgentNetworkPage(browser: Browser): Promise<{ page: Page; close: () => Promise; @@ -71,8 +108,17 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => { "management build has no POST /agent-network/settings, so the " + "wizard cannot bootstrap the account before the first create", ); + // The Kimi-gated config surfaces live on the Connect Agent page, whose + // caller-scoped GET /agent-network/agent-config answer ships with + // newer management builds. The suite starts running once the backend + // under test carries it. + test.skip( + !(await supportsAgentNetworkAgentConfig(page)), + "management build has no GET /agent-network/agent-config, so the " + + "Connect Agent page cannot render the agent config", + ); - await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX); + await cleanupKimiFixtures(page); await page.goto("/agent-network/providers"); await page.keyboard.press("Escape"); @@ -153,17 +199,36 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => { .click({ force: true }); const created = await Promise.race([createResponse, bootstrapRejected]); expect([200, 201]).toContain(created.status()); + const createdProvider = (await created.json()) as { id: string }; // Row lands in the providers table. await expect(page.getByText(providerName).first()).toBeVisible(); // ---- Kimi-gated agent config surfaces ---- - await page - .getByRole("button", { name: "Agent Config" }) - .click({ force: true }); + // The agent config lives inline on the Connect Agent page — the + // providers page keeps only the endpoint URL and Copy. That page's + // answer is caller-scoped: it offers only providers the caller's own + // policies authorize, so grant the caller the new provider through a + // dedicated group + policy (removed again by cleanupKimiFixtures). + const caller = await getCurrentUser(page); + const grantGroup = await createGroup(page, `${PROVIDER_PREFIX}grant`); + await updateUserAutoGroups(page, caller, [ + ...(caller.auto_groups ?? []), + grantGroup.id, + ]); + await createAgentNetworkPolicy(page, { + name: `${PROVIDER_PREFIX}policy`, + source_groups: [grantGroup.id], + destination_provider_ids: [createdProvider.id], + }); + + await page.goto("/agent-network/connect"); - // Kimi CLI tab only renders when a kimi_api provider is connected. - await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible(); + // Kimi CLI tab only renders when a kimi_api provider is connected. The + // longer timeout covers the page's caller-scoped agent-config fetch. + await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible({ + timeout: 15_000, + }); // Claude Code tab's backend dropdown offers (and, with Kimi as the only // Anthropic-shaped provider, pre-selects) Kimi — its settings.json @@ -192,10 +257,8 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => { await expect(page.getByText('wire_api = "responses"')).toBeVisible(); await expect(page.getByText('wire_api = "chat"')).not.toBeVisible(); - await page.keyboard.press("Escape"); - // ---- cleanup ---- - await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX); + await cleanupKimiFixtures(page); } finally { await close(); } diff --git a/src/app/(dashboard)/agent-network/configuration/page.tsx b/src/app/(dashboard)/agent-network/configuration/page.tsx index 62cea21cf..2d32b87b6 100644 --- a/src/app/(dashboard)/agent-network/configuration/page.tsx +++ b/src/app/(dashboard)/agent-network/configuration/page.tsx @@ -8,8 +8,8 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess"; import { VerticalTabs } from "@components/VerticalTabs"; import * as Tabs from "@radix-ui/react-tabs"; import { ExternalLinkIcon, Gauge, ScrollText, ServerIcon } from "lucide-react"; -import { useSearchParams } from "next/navigation"; -import React, { lazy, Suspense, useEffect, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import React, { lazy, Suspense, useMemo } from "react"; import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon"; import GroupsProvider from "@/contexts/GroupsProvider"; import PeersProvider from "@/contexts/PeersProvider"; @@ -38,15 +38,32 @@ export default function AgentNetworkConfigurationPage() { const { only: agentNetworkOnly } = useAgentNetworkMode(); const queryParams = useSearchParams(); const queryTab = queryParams.get("tab"); - const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS); + const router = useRouter(); + const pathname = usePathname(); - useEffect(() => { - if (queryTab) setTab(queryTab); - }, [queryTab]); + // Clusters is a reverse-proxy surface (its table and controls run on the + // services permission), so it stays hidden from roles that only hold + // agent_network.settings, e.g. agent_network_admin. Unknown ?tab= values + // fall back to the first tab so a matching content pane always renders. + const canReadClusters = !!permission?.services?.read; + const selectableTabs = useMemo(() => { + const tabs = new Set([TAB_BUDGET_SETTINGS, TAB_LOG_SETTINGS]); + if (canReadClusters) tabs.add(TAB_CLUSTERS); + return tabs; + }, [canReadClusters]); + + // The ?tab= query is the single source of truth. Trigger clicks push it + // themselves; onChange covers Radix's keyboard navigation, which fires + // onValueChange without a click. + const tab = + queryTab && selectableTabs.has(queryTab) ? queryTab : TAB_BUDGET_SETTINGS; + const onTabChange = (value: string) => { + router.push(`${pathname}?tab=${value}`, { scroll: false }); + }; return ( - + @@ -56,14 +73,16 @@ export default function AgentNetworkConfigurationPage() { Log Collection - - - Clusters - + {canReadClusters && ( + + + Clusters + + )} @@ -96,26 +115,28 @@ export default function AgentNetworkConfigurationPage() { - - - {agentNetworkOnly - ? "Proxy clusters route your agents' traffic to AI providers and run on your own infrastructure. Add multiple clusters to scale your environment." - : "Proxy clusters route inbound traffic to your services. Shared clusters are run by the platform; account clusters (self-hosted) run on your own infrastructure."}{" "} - + - Learn more - - - - }> - - - + {agentNetworkOnly + ? "Proxy clusters route your agents' traffic to AI providers and run on your own infrastructure. Add multiple clusters to scale your environment." + : "Proxy clusters route inbound traffic to your services. Shared clusters are run by the platform; account clusters (self-hosted) run on your own infrastructure."}{" "} + + Learn more + + + + }> + + + + )} diff --git a/src/app/(dashboard)/agent-network/connect/page.tsx b/src/app/(dashboard)/agent-network/connect/page.tsx new file mode 100644 index 000000000..3280519c5 --- /dev/null +++ b/src/app/(dashboard)/agent-network/connect/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import React from "react"; +import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon"; +import PageContainer from "@/layouts/PageContainer"; +import { AgentConnectTabs } from "@/modules/agent-network/AgentConnectTabs"; +import EndpointBadge from "@/modules/agent-network/EndpointBadge"; +import ConnectProvidersTable from "@/modules/agent-network/table/ConnectProvidersTable"; +import { + APIMeSetup, + useMyAgentNetworkSetup, +} from "@/modules/agent-network/useMyAgentNetworkSetup"; + +// ConnectAgentPage is the caller-scoped self-service view: the endpoint to +// configure tools with and the per-tool config that goes with it — the one +// place the agent config lives — plus the providers and models the caller's +// own policies allow. It needs no agent_network permission (the backing +// endpoint answers for the caller only), so every role, including plain users +// in the limited view, gets the same config. A caller no policy covers yet +// still gets it, with an empty provider list carrying the explanation. The caller's own usage lives on the regular Usage & Logs page, +// which the server scopes to them. +export default function ConnectAgentPage() { + const { setup, isLoading } = useMyAgentNetworkSetup(); + + return ( + +
+ + } + /> + + +

Connect Your Agent

+ + Point your agent at the NetBird endpoint as its base URL. No provider + API key is required on the client. NetBird authenticates you through + your identity provider and authorizes each request against your access + policies. + + + {isLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ ); +} + +function ConnectAgentSetup({ setup }: { setup?: APIMeSetup }) { + const providers = setup?.providers ?? []; + // EndpointBadge builds https:// URLs from a bare host. + const bareEndpoint = (setup?.endpoint ?? "").replace(/^https?:\/\//, ""); + const providerIds = providers.map((provider) => provider.catalog_id); + + return ( + <> + {/* The server hands the endpoint to every member of an account that has + Agent Network set up, covered by a policy or not, so this renders for + everyone; it stays guarded because an account with no endpoint yet + has nothing to copy and no snippet that would work. */} + {bareEndpoint && ( + <> +
+ +
+ +
+ {/* Same 16px step the endpoint card sits below the description + by. */} + +
+ + )} + +
+

Your Providers & Models

+ +
+ + ); +} diff --git a/src/app/(dashboard)/agent-network/policies/page.tsx b/src/app/(dashboard)/agent-network/policies/page.tsx index 9b4eb789d..b29e890b2 100644 --- a/src/app/(dashboard)/agent-network/policies/page.tsx +++ b/src/app/(dashboard)/agent-network/policies/page.tsx @@ -43,7 +43,7 @@ export default function AgentNetworkPoliciesPage() { }> diff --git a/src/app/(dashboard)/agent-network/providers/page.tsx b/src/app/(dashboard)/agent-network/providers/page.tsx index 64a5ee5ef..b987d8eee 100644 --- a/src/app/(dashboard)/agent-network/providers/page.tsx +++ b/src/app/(dashboard)/agent-network/providers/page.tsx @@ -6,93 +6,28 @@ import Paragraph from "@components/Paragraph"; import SkeletonTable from "@components/skeletons/SkeletonTable"; import { RestrictedAccess } from "@components/ui/RestrictedAccess"; import { usePortalElement } from "@hooks/usePortalElement"; -import useCopyToClipboard from "@hooks/useCopyToClipboard"; -import { Copy, ExternalLinkIcon, Globe, Plug } from "lucide-react"; -import React, { Suspense, useState } from "react"; +import { ExternalLinkIcon, Globe } from "lucide-react"; +import React, { Suspense } from "react"; import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import PageContainer from "@/layouts/PageContainer"; -import AgentConnectModal from "@/modules/agent-network/AgentConnectModal"; import AIProviderModal from "@/modules/agent-network/AIProviderModal"; import AIProvidersProvider, { useAIProviders, } from "@/modules/agent-network/AIProvidersProvider"; +import EndpointBadge from "@/modules/agent-network/EndpointBadge"; import AgentProvidersTable from "@/modules/agent-network/table/AgentProvidersTable"; import InlineLink from "@components/InlineLink"; -function EndpointBadge({ endpoint }: { endpoint: string }) { - const [, copy] = useCopyToClipboard(`https://${endpoint}`); - const [connectOpen, setConnectOpen] = useState(false); - return ( -
-
-
- API Base URL - - Use this URL as the base URL when configuring your AI agents or - LLM SDK clients (e.g. OpenAI's - base_url, Anthropic's{" "} - baseURL, or any HTTP - client). Calls hit NetBird first, get authorised by your - policies, and only then reach the upstream provider. - - } - /> -
- - https://{endpoint} - -
- - - -
- ); -} - function EndpointHeader() { const { settings, settingsLoading, openWizard } = useAIProviders(); + const { permission } = usePermissions(); if (settingsLoading) return null; if (!settings) { + // The bootstrap CTA opens the provider wizard, so only callers who can + // actually connect a provider get it; read-only viewers (usage_viewer) + // see nothing until an admin sets the endpoint up. + if (!permission?.["agent_network.providers"]?.create) return null; return ( + + ); +} diff --git a/src/modules/agent-network/table/AgentProviderActionCell.tsx b/src/modules/agent-network/table/AgentProviderActionCell.tsx index d0a3925a9..5f5bf3983 100644 --- a/src/modules/agent-network/table/AgentProviderActionCell.tsx +++ b/src/modules/agent-network/table/AgentProviderActionCell.tsx @@ -10,6 +10,7 @@ import FullTooltip from "@components/FullTooltip"; import { MoreVertical, Power, Trash2 } from "lucide-react"; import * as React from "react"; import { useDialog } from "@/contexts/DialogProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; import { AIProvider } from "@/modules/agent-network/data/mockData"; import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider"; @@ -20,6 +21,11 @@ type Props = { export default function AgentProviderActionCell({ provider }: Readonly) { const { confirm } = useDialog(); const { policies, toggleProvider, deleteProvider } = useAIProviders(); + // Each menu item maps to its own operation grant; read-only viewers + // (usage_viewer) get no menu at all instead of actions that can only 403. + const { permission } = usePermissions(); + const canUpdate = !!permission?.["agent_network.providers"]?.update; + const canDelete = !!permission?.["agent_network.providers"]?.delete; const referencingPolicies = policies.filter((p) => p.destinationProviderIds.includes(provider.id), @@ -39,6 +45,8 @@ export default function AgentProviderActionCell({ provider }: Readonly) { await deleteProvider(provider.id); }; + if (!canUpdate && !canDelete) return null; + return (
@@ -54,45 +62,49 @@ export default function AgentProviderActionCell({ provider }: Readonly) { - toggleProvider(provider.id)}> -
- - {provider.enabled ? "Disable" : "Enable"} -
-
- - - - - This provider is referenced by{" "} - {referencingPolicies.length === 1 - ? "1 policy" - : `${referencingPolicies.length} policies`}{" "} - and cannot be deleted. Detach it from the policy first. -
- } - > - { - if (inUse) { - e.preventDefault(); - return; - } - handleDelete(); - }} - variant={"danger"} - disabled={inUse} - > + {canUpdate && ( + toggleProvider(provider.id)}>
- - Delete + + {provider.enabled ? "Disable" : "Enable"}
- + )} + + {canUpdate && canDelete && } + + {canDelete && ( + + This provider is referenced by{" "} + {referencingPolicies.length === 1 + ? "1 policy" + : `${referencingPolicies.length} policies`}{" "} + and cannot be deleted. Detach it from the policy first. + + } + > + { + if (inUse) { + e.preventDefault(); + return; + } + handleDelete(); + }} + variant={"danger"} + disabled={inUse} + > +
+ + Delete +
+
+
+ )} diff --git a/src/modules/agent-network/table/AgentProvidersTable.tsx b/src/modules/agent-network/table/AgentProvidersTable.tsx index b553d5781..6aadc801f 100644 --- a/src/modules/agent-network/table/AgentProvidersTable.tsx +++ b/src/modules/agent-network/table/AgentProvidersTable.tsx @@ -13,6 +13,7 @@ import { ExternalLinkIcon, PlusCircle } from "lucide-react"; import { usePathname } from "next/navigation"; import React, { useState } from "react"; import AIAccessIcon from "@/assets/icons/AgentNetworkIcon"; +import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLocalStorage } from "@/hooks/useLocalStorage"; import { AIProvider } from "@/modules/agent-network/data/mockData"; import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider"; @@ -57,7 +58,9 @@ function NameCell({ provider }: { provider: AIProvider }) {

@@ -67,11 +70,11 @@ function NameCell({ provider }: { provider: AIProvider }) { function ModelsCell({ provider }: { provider: AIProvider }) { if (provider.models.length === 0) { - return All models; + return All Models; } return ( - {provider.models.length} configured + {provider.models.length} Models ); } @@ -112,6 +115,11 @@ export default function AgentProvidersTable({ }: Readonly) { const path = usePathname(); const { providers, isLoading } = useAIProviders(); + // Read-only viewers (usage_viewer) see the list but no write flows: the + // edit modal needs update, and opening it would also mislead them with + // the bootstrap warning since they can't read the settings row. + const { permission } = usePermissions(); + const canUpdate = !!permission?.["agent_network.providers"]?.update; const [sorting, setSorting] = useLocalStorage( "netbird-table-sort" + path, @@ -136,65 +144,73 @@ export default function AgentProvidersTable({ /> )} { - setEditingProvider(row.original); - setEditOpen(true); - }} - getStartedCard={ - } - color={"gray"} - size={"large"} - /> - } - title={"Connect a provider"} - description={ - "Route OpenAI, Anthropic, and other LLM APIs through NetBird to enforce access control, track token spend, and capture prompts." - } - button={ -
+ headingTarget={headingTarget} + isLoading={isLoading} + text={"Providers"} + sorting={sorting} + setSorting={setSorting} + columns={columns} + data={providers} + searchPlaceholder={"Search by name..."} + onRowClick={ + canUpdate + ? (row) => { + setEditingProvider(row.original); + setEditOpen(true); + } + : undefined + } + getStartedCard={ + } + color={"gray"} + size={"large"} + /> + } + title={"Connect a provider"} + description={ + "Route OpenAI, Anthropic, and other LLM APIs through NetBird to enforce access control, track token spend, and capture prompts." + } + button={ +
+ +
+ } + learnMore={ + <> + Learn more about + + Agent Network Providers + + + + } + /> + } + rightSide={() => + providers.length > 0 && ( +
- } - learnMore={ - <> - Learn more about - - Agent Network Providers - - - - } - /> - } - rightSide={() => - providers.length > 0 && ( -
- -
- ) - } - initialPageSize={25} - /> + ) + } + initialPageSize={25} + /> ); } const AddProviderButton = () => { const { openWizard } = useAIProviders(); + const { permission } = usePermissions(); + // Connecting a provider needs the create grant; read-only viewers get no + // button instead of a wizard that can only fail. + if (!permission?.["agent_network.providers"]?.create) return null; return (
+ ); +} + +const columns: ColumnDef[] = [ + { + id: "name", + accessorKey: "name", + sortingFn: "text", + header: ({ column }) => ( + Provider + ), + cell: ({ row }) => , + }, + { + id: "models", + // All-models rows sort above allow-listed ones, then by list length. + accessorFn: (p) => (p.all_models_allowed ? Infinity : p.models.length), + sortingFn: "basic", + header: ({ column }) => ( + Models + ), + cell: ({ row }) => , + }, +]; + +type Props = { + providers: APIMeProvider[]; +}; + +// ConnectProvidersTable lists what the caller's own policies let them reach. +// Same DataTable the admin providers table uses, minus the write flows: the +// rows come from the caller-scoped agent-config answer, so there is nothing +// here to connect, edit, or delete. +export default function ConnectProvidersTable({ providers }: Readonly) { + const path = usePathname(); + const router = useRouter(); + const [sorting, setSorting] = useLocalStorage( + "netbird-table-sort" + path, + [{ id: "name", desc: false }], + ); + + // Whoever can edit policies can fix this themselves, so they get the action + // instead of being told to ask someone else. + const { permission } = usePermissions(); + const canManagePolicies = !!permission?.["agent_network.policies"]?.update; + + return ( + + } + color={"gray"} + size={"large"} + /> + } + title={"No providers available yet"} + description={ + canManagePolicies + ? "No access policy covers your user yet. Add one of your groups to a policy to route your own agent through NetBird." + : "You don’t have access to any providers yet. Ask your administrator to add you to an Agent Network access policy." + } + button={ + canManagePolicies ? ( + + ) : undefined + } + /> + } + /> + ); +} diff --git a/src/modules/agent-network/useAgentNetworkMode.ts b/src/modules/agent-network/useAgentNetworkMode.ts index bd7f1e27a..97e249296 100644 --- a/src/modules/agent-network/useAgentNetworkMode.ts +++ b/src/modules/agent-network/useAgentNetworkMode.ts @@ -7,6 +7,7 @@ import { SIGNUP_SOURCE_LOCAL_STORAGE_KEY, } from "@/hooks/useSignupSource"; import { Account } from "@/interfaces/Account"; +import { useMyAgentNetworkSetup } from "@/modules/agent-network/useMyAgentNetworkSetup"; /** * Report whether a new account arrived from the netbird.ai signup source and @@ -46,6 +47,33 @@ export const useAgentNetworkMode = () => { permission.accounts.read, ); + // The caller-scoped agent config answers "configured" only when the account + // has an Agent Network endpoint, so it stands in as proof the surface + // exists for callers who cannot resolve the flag themselves — the same + // fallback shape as the grant check below, and gated the same way, so a + // deployment that turns the surface off still turns it off for everyone + // who can read that decision. + const { configured: agentConfigured, isLoading: isAgentConfigLoading } = + useMyAgentNetworkSetup(); + const hasAgentConfig = !permission?.accounts?.read && agentConfigured; + + // Resolving the flag needs accounts read, which the delegated roles below + // account admin (usage_viewer, and agent_network scopes granted to custom + // roles) may not hold. For them, holding an explicit agent_network grant + // is proof enough the surface exists — the grants only exist on + // deployments that have it. Callers WITH accounts read keep the flag as + // the source of truth, so admins on deployments without the surface + // don't get the menu from their blanket grants. + const hasAgentNetworkGrant = + !permission?.accounts?.read && + !!( + permission?.["agent_network.providers"]?.read || + permission?.["agent_network.policies"]?.read || + permission?.["agent_network.usage"]?.read || + permission?.["agent_network.logs"]?.read || + permission?.["agent_network.settings"]?.read + ); + return useMemo(() => { const account = accounts?.[0]; // Deployment config is a floor: NETBIRD_AGENT_NETWORK_ONLY focuses the @@ -61,8 +89,23 @@ export const useAgentNetworkMode = () => { // alongside the full dashboard (unlike "only", which hides everything else). const featureEnabled = account?.settings?.dashboard_features?.agent_network === true; - const enabled = only || featureEnabled || isAgentNetworkEnabled(); - const loading = permission.accounts.read ? isLoading : false; + const enabled = + only || + featureEnabled || + isAgentNetworkEnabled() || + hasAgentNetworkGrant || + hasAgentConfig; + // Both answers gate the route tree, so neither may resolve late: the + // layout renders nothing while loading and 404s the moment it is false. + const loading = + (permission.accounts.read ? isLoading : false) || isAgentConfigLoading; return { only, enabled, loading } as const; - }, [accounts, isLoading, permission.accounts.read]); + }, [ + accounts, + isLoading, + permission.accounts.read, + hasAgentNetworkGrant, + hasAgentConfig, + isAgentConfigLoading, + ]); }; diff --git a/src/modules/agent-network/useMyAgentNetworkSetup.ts b/src/modules/agent-network/useMyAgentNetworkSetup.ts new file mode 100644 index 000000000..f7618abf9 --- /dev/null +++ b/src/modules/agent-network/useMyAgentNetworkSetup.ts @@ -0,0 +1,39 @@ +import useFetchApi from "@utils/api"; + +// Wire types for the caller-scoped self-service endpoints. Both answers are +// computed server-side from the caller's own group memberships with the same +// rules the proxy enforces, so no agent_network permission is required to +// read them. +export type APIMeProvider = { + name: string; + catalog_id: string; + api_flavor: string; + all_models_allowed: boolean; + models: string[]; +}; + +export type APIMeSetup = { + configured: boolean; + endpoint: string; + providers: APIMeProvider[]; +}; + +/** + * Fetch the caller's effective Agent Network setup. `configured` doubles as + * the visibility switch for the self-service pages: the server deliberately + * answers "not configured" both when the account has no Agent Network and + * when the caller's policies grant no access, so a false here means there is + * nothing to show this user. Errors are ignored so a management server + * without the endpoint degrades to the section staying hidden. + */ +export const useMyAgentNetworkSetup = () => { + const { data: setup, isLoading } = useFetchApi( + "/agent-network/agent-config", + true, + ); + return { + setup, + configured: setup?.configured === true, + isLoading, + } as const; +}; diff --git a/src/modules/onboarding/OnboardingProvider.tsx b/src/modules/onboarding/OnboardingProvider.tsx index b17548a07..ffe71055d 100644 --- a/src/modules/onboarding/OnboardingProvider.tsx +++ b/src/modules/onboarding/OnboardingProvider.tsx @@ -10,14 +10,15 @@ import { useMemo } from "react"; import { useSWRConfig } from "swr"; import { submitHubspotForm } from "@/cloud/analytics/Hubspot"; import { HubspotFormField, useAnalytics } from "@/contexts/AnalyticsProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; 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 { 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"; @@ -58,7 +59,16 @@ export const OnboardingProvider = ({ onSurveySubmit, domainCategory, }: Props) => { - const { data: peers } = useFetchApi("/peers"); + const { permission } = usePermissions(); + // Onboarding only cares whether the account has peers yet. Roles without + // peers read (agent_network_admin, usage_viewer) would just collect a 403 + // toast on every page, so skip the call for them entirely. + const { data: peers } = useFetchApi( + "/peers", + true, + true, + permission.peers.read, + ); const accountRequest = useApiCall("/accounts", true); const account = useAccount(); const router = useRouter(); diff --git a/src/modules/onboarding/agent-network/OnboardingAgentConfigure.tsx b/src/modules/onboarding/agent-network/OnboardingAgentConfigure.tsx index 176824b56..b839ab134 100644 --- a/src/modules/onboarding/agent-network/OnboardingAgentConfigure.tsx +++ b/src/modules/onboarding/agent-network/OnboardingAgentConfigure.tsx @@ -1,7 +1,7 @@ import Button from "@components/Button"; import { ArrowRightIcon } from "lucide-react"; import * as React from "react"; -import { AgentConnectTabs } from "@/modules/agent-network/AgentConnectModal"; +import { AgentConnectTabs } from "@/modules/agent-network/AgentConnectTabs"; import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider"; type Props = { diff --git a/src/modules/users/UserInvitesTable.tsx b/src/modules/users/UserInvitesTable.tsx index 1cbcb68d7..2378b7638 100644 --- a/src/modules/users/UserInvitesTable.tsx +++ b/src/modules/users/UserInvitesTable.tsx @@ -1,3 +1,4 @@ +import Badge from "@components/Badge"; import Button from "@components/Button"; import Code from "@components/Code"; import { @@ -9,6 +10,7 @@ import { } from "@components/DropdownMenu"; import InlineLink from "@components/InlineLink"; import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal"; +import { notify } from "@components/Notification"; import Paragraph from "@components/Paragraph"; import SquareIcon from "@components/SquareIcon"; import { DataTable } from "@components/table/DataTable"; @@ -36,44 +38,45 @@ import { } from "@components/table/TableFilters"; import GetStartedTest from "@components/ui/GetStartedTest"; import MultipleGroups from "@components/ui/MultipleGroups"; -import Skeleton from "react-loading-skeleton"; import { ColumnDef, SortingState } from "@tanstack/react-table"; import useFetchApi, { useApiCall } from "@utils/api"; -import { notify } from "@components/Notification"; -import { MoreVertical, RefreshCw } from "lucide-react"; +import { cn, generateColorFromString } from "@utils/helpers"; import { isNetBirdCloud } from "@utils/netbird"; import dayjs from "dayjs"; +import { MoreVertical, RefreshCw } from "lucide-react"; import { Cog, CopyIcon, CreditCardIcon, ExternalLinkIcon, EyeIcon, + GaugeIcon, Link2, MailPlus, NetworkIcon, Trash2, User2, } from "lucide-react"; -import NetBirdIcon from "@/assets/icons/NetBirdIcon"; -import Badge from "@components/Badge"; import { usePathname } from "next/navigation"; import React, { useMemo, useState } from "react"; +import Skeleton from "react-loading-skeleton"; import { useSWRConfig } from "swr"; +import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon"; +import NetBirdIcon from "@/assets/icons/NetBirdIcon"; import { useDialog } from "@/contexts/DialogProvider"; import { useGroups } from "@/contexts/GroupsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import useCopyToClipboard from "@/hooks/useCopyToClipboard"; import { useLocalStorage } from "@/hooks/useLocalStorage"; -import { cn, generateColorFromString } from "@utils/helpers"; import { Group } from "@/interfaces/Group"; import { Role, UserInvite, UserInviteRegenerateResponse, } from "@/interfaces/User"; -import UserInviteModal from "@/modules/users/UserInviteModal"; import { useAccount } from "@/modules/account/useAccount"; +import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode"; +import UserInviteModal from "@/modules/users/UserInviteModal"; // Name cell for invites - same styling as UserNameCell but for invites function InviteNameCell({ invite }: { invite: UserInvite }) { @@ -145,6 +148,18 @@ function InviteRoleCell({ invite }: { invite: UserInvite }) { Network Admin )} + {role === Role.AgentNetworkAdmin && ( + <> + + Agent Network Admin + + )} + {role === Role.UsageViewer && ( + <> + + Usage Viewer + + )} ); @@ -465,6 +480,8 @@ export default function UserInvitesTable({ ], ); + const { enabled: agentNetworkEnabled } = useAgentNetworkMode(); + const invitesWithGroupNames = useMemo(() => { if (!invites) return undefined; return invites.map((invite) => ({ @@ -500,10 +517,21 @@ export default function UserInvitesTable({ { value: "admin", label: "Admin" }, { value: "user", label: "User" }, { value: "network_admin", label: "Network Admin" }, + // Agent Network roles can only be assigned where the surface exists, so + // don't offer them as filters elsewhere. + ...(agentNetworkEnabled + ? [ + { + value: "agent_network_admin", + label: "Agent Network Admin", + }, + { value: "usage_viewer", label: "Usage Viewer" }, + ] + : []), { value: "billing_admin", label: "Billing Admin" }, { value: "auditor", label: "Auditor" }, ], - [], + [agentNetworkEnabled], ); const filterDefs = useMemo( diff --git a/src/modules/users/UserRoleSelector.tsx b/src/modules/users/UserRoleSelector.tsx index 363fe8d83..ddcc2e58a 100644 --- a/src/modules/users/UserRoleSelector.tsx +++ b/src/modules/users/UserRoleSelector.tsx @@ -2,6 +2,8 @@ import Button from "@components/Button"; import { CommandItem } from "@components/Command"; import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover"; import { ScrollArea } from "@components/ScrollArea"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; +import { cn } from "@utils/helpers"; import { isNetBirdCloud } from "@utils/netbird"; import { Command, CommandGroup, CommandList } from "cmdk"; import { trim } from "lodash"; @@ -10,17 +12,21 @@ import { Cog, CreditCard, EyeIcon, + GaugeIcon, NetworkIcon, User2, + UsersIcon, } from "lucide-react"; import * as React from "react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; +import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; import { useDialog } from "@/contexts/DialogProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; import { useElementSize } from "@/hooks/useElementSize"; import { Role, User } from "@/interfaces/User"; +import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode"; interface MultiSelectProps { value?: Role; @@ -35,39 +41,149 @@ interface MultiSelectProps { align?: "start" | "center" | "end"; } -export const UserRoles = [ +// Roles are grouped by the product surface they grant access to, so the list +// stays readable as more surfaces ship their own delegated roles. Add a +// category here (and a `category` on the roles) when the next surface lands. +export type UserRoleCategory = "general" | "agent-network"; + +type IconComponent = React.ComponentType<{ + size?: number; + width?: number; + className?: string; +}>; + +export const UserRoleCategories: { + value: UserRoleCategory; + name: string; + icon: IconComponent; +}[] = [ + { value: "general", name: "General", icon: UsersIcon }, + { value: "agent-network", name: "Agent Network", icon: AgentNetworkIcon }, +]; + +export const UserRoles: { + name: string; + // Label used inside the role's own tab, where the category is already in the + // tab title — "Agent Network Admin" reads as just "Admin" under Agent Network. + shortName?: string; + value: Role; + icon: IconComponent; + category: UserRoleCategory; + description: string; +}[] = [ { name: "Owner", value: Role.Owner, icon: NetBirdIcon, + category: "general", + description: "Full access, including transferring ownership.", }, { name: "Admin", value: Role.Admin, icon: Cog, + category: "general", + description: "Manages users, peers, networks and account settings.", }, { name: "Network Admin", value: Role.NetworkAdmin, icon: NetworkIcon, + category: "general", + description: "Manages peers, networks and access control.", }, { name: "Billing Admin", value: Role.BillingAdmin, icon: CreditCard, + category: "general", + description: "Manages the subscription and billing details.", }, { name: "Auditor", value: Role.Auditor, icon: EyeIcon, + category: "general", + description: "Read-only access to the configuration and audit events.", }, { name: "User", value: Role.User, icon: User2, + category: "general", + description: "Access to their own peers only.", + }, + { + name: "Agent Network Admin", + shortName: "Admin", + value: Role.AgentNetworkAdmin, + icon: AgentNetworkIcon, + category: "agent-network", + description: "Manages AI providers, agent policies and guardrails.", + }, + { + name: "Usage Viewer", + value: Role.UsageViewer, + icon: GaugeIcon, + category: "agent-network", + description: "Read-only access to usage and logs.", }, ]; +const RoleList = ({ + roles, + onSelect, + useShortNames = false, + fixedHeight = false, +}: { + roles: typeof UserRoles; + onSelect: (role: Role) => void; + useShortNames?: boolean; + // Reserve the same height for every tab. Tabs hold different numbers of + // roles, and a popover that changes height mid-interaction gets re-positioned + // by the collision handling — it would open downwards on a short tab and flip + // upwards on a long one. + fixedHeight?: boolean; +}) => { + return ( + + +
+ {roles.map((item) => ( + onSelect(item.value)} + onClick={(e) => e.preventDefault()} + > +
+
+ +
+
+ + {useShortNames ? (item.shortName ?? item.name) : item.name} + + + {item.description} + +
+
+
+ ))} +
+
+
+ ); +}; + export function UserRoleSelector({ onChange, value, @@ -125,11 +241,60 @@ export function UserRoleSelector({ // Cloud only const { isAccountWithMSPParent } = useMSP(); + const { enabled: agentNetworkEnabled } = useAgentNetworkMode(); + + const categories = useMemo(() => { + const isVisible = (role: Role) => { + if (!isOwner && role === Role.Owner) return false; + if (hideOwner && role === Role.Owner) return false; + if (hideBillingAdmin && role === Role.BillingAdmin) return false; + + // Cloud only + if (role === Role.BillingAdmin && !isNetBirdCloud()) return false; + if (role === Role.BillingAdmin && isAccountWithMSPParent) return false; + if (role === Role.Owner && isAccountWithMSPParent) return false; + + return true; + }; + + return UserRoleCategories.map((category) => ({ + ...category, + roles: UserRoles.filter( + (role) => role.category === category.value && isVisible(role.value), + ), + })).filter((category) => { + // Deployments without the Agent Network surface don't get its roles. + if (category.value === "agent-network" && !agentNetworkEnabled) + return false; + return category.roles.length > 0; + }); + }, [ + isOwner, + hideOwner, + hideBillingAdmin, + isAccountWithMSPParent, + agentNetworkEnabled, + ]); + + // A single group is just a list — the tab row would only add noise. + const showTabs = categories.length > 1; + const [tab, setTab] = useState( + selectedRole?.category ?? UserRoleCategories[0].value, + ); + // Fall back to the first group when the remembered tab is not available, e.g. + // when the role was cleared or a group got filtered out. + const activeTab = categories.some((category) => category.value === tab) + ? tab + : (categories[0]?.value ?? UserRoleCategories[0].value); + return ( { setOpen(isOpen); + // Open on the group the current role lives in, so the selection is + // visible without hunting for it. + if (isOpen && selectedRole) setTab(selectedRole.category); }} > @@ -145,19 +310,27 @@ export function UserRoleSelector({ className={"w-full group/user-role-selector"} data-testid={"user-role-selector"} > -
+
{selectedRole && ( -
- -
- +
+
+ +
+
+ {/* Truncate instead of overflowing the button: role names + can be longer than the column they sit in. */} + {selectedRole?.name}
)} -
+
@@ -168,10 +341,17 @@ export function UserRoleSelector({ className="w-full p-0 shadow-sm shadow-nb-gray-950" style={{ width: popoverWidth === "auto" ? width : popoverWidth, + // The tab row needs more room than the trigger usually offers. + minWidth: showTabs ? 320 : undefined, }} - align={align} + // When the popover is wider than the trigger, anchor it to the + // trigger's right edge so the extra width grows inwards — the role + // selector sits in a right-hand column, so growing to the right would + // run off the screen. + align={showTabs ? "end" : align} side={side} sideOffset={10} + collisionPadding={12} > - - -
- {UserRoles.map((item) => { - if (!isOwner && item.value === Role.Owner) return null; - if (hideOwner && item.value === Role.Owner) return null; - if (hideBillingAdmin && item.value === Role.BillingAdmin) - return null; - - // Cloud only - if (item.value === Role.BillingAdmin && !isNetBirdCloud()) - return null; - if ( - item.value === Role.BillingAdmin && - isAccountWithMSPParent - ) - return null; - if (item.value === Role.Owner && isAccountWithMSPParent) - return null; - - return ( - toggle(item.value)} - onClick={(e) => e.preventDefault()} - > -
- -
- {item.name} -
-
-
- ); - })} -
-
-
+ {showTabs ? ( + + + {categories.map((category) => ( + + + {category.name} + + ))} + + {categories.map((category) => ( + + + + ))} + + ) : ( + category.roles)} + onSelect={toggle} + /> + )}
diff --git a/src/modules/users/UsersTable.tsx b/src/modules/users/UsersTable.tsx index d1b0532f5..ba1923273 100644 --- a/src/modules/users/UsersTable.tsx +++ b/src/modules/users/UsersTable.tsx @@ -43,12 +43,14 @@ import { usePathname, useRouter } from "next/navigation"; import React, { useMemo, useState } from "react"; import { useSWRConfig } from "swr"; import TeamIcon from "@/assets/icons/TeamIcon"; +import { useGroups } from "@/contexts/GroupsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLocalStorage } from "@/hooks/useLocalStorage"; import { Group } from "@/interfaces/Group"; import { User, UserInvite } from "@/interfaces/User"; +import { useAccount } from "@/modules/account/useAccount"; +import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode"; import LastTimeRow from "@/modules/common-table-rows/LastTimeRow"; -import { useGroups } from "@/contexts/GroupsProvider"; import { PendingApprovalFilter } from "@/modules/users/PendingApprovalFilter"; import UserActionCell from "@/modules/users/table-cells/UserActionCell"; import UserGroupCell from "@/modules/users/table-cells/UserGroupCell"; @@ -57,7 +59,6 @@ import UserRoleCell from "@/modules/users/table-cells/UserRoleCell"; import UserStatusCell from "@/modules/users/table-cells/UserStatusCell"; import UserInviteModal from "@/modules/users/UserInviteModal"; import UserInvitesTable from "@/modules/users/UserInvitesTable"; -import { useAccount } from "@/modules/account/useAccount"; export const UsersTableColumns: ColumnDef[] = [ { @@ -211,6 +212,7 @@ export default function UsersTable({ const router = useRouter(); const { permission } = usePermissions(); + const { enabled: agentNetworkEnabled } = useAgentNetworkMode(); const usersWithGroupNames = useMemo(() => { if (!users) return undefined; @@ -249,10 +251,21 @@ export default function UsersTable({ { value: "admin", label: "Admin" }, { value: "user", label: "User" }, { value: "network_admin", label: "Network Admin" }, + // Agent Network roles can only be assigned where the surface exists, so + // don't offer them as filters elsewhere. + ...(agentNetworkEnabled + ? [ + { + value: "agent_network_admin", + label: "Agent Network Admin", + }, + { value: "usage_viewer", label: "Usage Viewer" }, + ] + : []), { value: "billing_admin", label: "Billing Admin" }, { value: "auditor", label: "Auditor" }, ], - [], + [agentNetworkEnabled], ); const filterDefs = useMemo( diff --git a/src/modules/users/table-cells/UserRoleCell.tsx b/src/modules/users/table-cells/UserRoleCell.tsx index 08fb7d8e7..6461013bd 100644 --- a/src/modules/users/table-cells/UserRoleCell.tsx +++ b/src/modules/users/table-cells/UserRoleCell.tsx @@ -1,7 +1,15 @@ import Badge from "@components/Badge"; import { cn } from "@utils/helpers"; -import { Cog, CreditCardIcon, EyeIcon, NetworkIcon, User2 } from "lucide-react"; +import { + Cog, + CreditCardIcon, + EyeIcon, + GaugeIcon, + NetworkIcon, + User2, +} from "lucide-react"; import React from "react"; +import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; import { Role, User } from "@/interfaces/User"; @@ -51,6 +59,18 @@ export default function UserRoleCell({ user }: Readonly) { Network Admin )} + {role === Role.AgentNetworkAdmin && ( + <> + + Agent Network Admin + + )} + {role === Role.UsageViewer && ( + <> + + Usage Viewer + + )}
); diff --git a/src/utils/version.ts b/src/utils/version.ts index 4a5523a06..f305f1365 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -84,17 +84,37 @@ export const compareVersions = ( return true; }; +/** + * Whether a version string names a non-release build. Mirrors the management + * server's version.IsDevelopmentVersion: the literal "development" plus the + * "ci-" and "dev-" prefixes it stamps on snapshot builds ("ci-7470fbdd"). + * + * Such a string carries no release to compare against. releaseParts() reads + * its leading word as 0, so without this check every snapshot install would + * see the current release as newer and nag about an update forever. + */ +export const isDevelopmentVersion = (version: string): boolean => { + const bare = version.trim().replace(/^v/i, ""); + return ( + bare.startsWith("development") || + bare.startsWith("ci-") || + bare.startsWith("dev-") + ); +}; + /** * Returns true when `latest` is a strictly newer release than `current` — i.e. * an update is available. Only release components decide: an enterprise build * ("0.77.0+enterprise.1") is up to date against the "0.77.0" it was built from, * matching how the management server evaluates it server-side. * - * "development" builds never report an update, in either position. + * Development and snapshot builds never report an update, in either position. */ export const isNewerVersion = (current: string, latest: string): boolean => { if (!current || !latest) return false; - if (current === "development" || latest === "development") return false; + if (isDevelopmentVersion(current) || isDevelopmentVersion(latest)) { + return false; + } const currentParts = releaseParts(current); const latestParts = releaseParts(latest);