Group user roles into General and Agent Network tabs

Tab the role dropdown by product surface, gate the Agent Network
  roles and tab on the Agent Network surface being available, and
  disable the role field for the owner unless the caller is an owner.
This commit is contained in:
braginini committed 2026-08-16 20:40:24 +02:00
1 parent eff4321229
commit 3fbeb1eb4a
4 files changed
+288 -94

No files matched your search

+21 -10
View File
@@ -93,10 +93,15 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
const userRequest = useApiCall<User>("/users");
const isServiceUser = !!user?.is_service_user;
const { mutate } = useSWRConfig();
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
const { loggedInUser, isOwner, isOwnerOrAdmin, isUser } = useLoggedInUser();
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
const { permission } = usePermissions();
// The management API rejects taking the owner role away from a user unless
// the caller is an owner themselves ("only owners can remove owner role from
// their user"), so don't offer the change to anyone else.
const cannotChangeOwnerRole = user.role === Role.Owner && !isOwner;
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: initialGroups,
@@ -250,20 +255,26 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
/>
</div>
)}
<div className={"flex items-start"}>
<div className={"w-2/3"}>
<Label>User Role</Label>
<HelpText>
Set a role for the user to assign access permissions.
</HelpText>
</div>
<div className={"w-1/3"}>
<div>
<Label>User Role</Label>
<HelpText>
{cannotChangeOwnerRole
? "Only the account owner can change the owner's role."
: "Set a role for the user to assign access permissions."}
</HelpText>
{/* 320px: enough for the longest role name and for the tab row
in the dropdown, without stretching across the column. */}
<div className={"max-w-[320px]"}>
<UserRoleSelector
value={role}
onChange={setRole}
hideOwner={isServiceUser}
currentUser={user}
disabled={isLoggedInUser || !permission.users.update}
disabled={
isLoggedInUser ||
!permission.users.update ||
cannotChangeOwnerRole
}
/>
</div>
</div>
+23 -11
View File
@@ -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,13 +38,12 @@ 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,
@@ -56,26 +57,26 @@ import {
Trash2,
User2,
} from "lucide-react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
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 }) {
@@ -479,6 +480,8 @@ export default function UserInvitesTable({
],
);
const { enabled: agentNetworkEnabled } = useAgentNetworkMode();
const invitesWithGroupNames = useMemo(() => {
if (!invites) return undefined;
return invites.map((invite) => ({
@@ -514,12 +517,21 @@ export default function UserInvitesTable({
{ value: "admin", label: "Admin" },
{ value: "user", label: "User" },
{ value: "network_admin", label: "Network Admin" },
{ value: "agent_network_admin", label: "Agent Network Admin" },
{ value: "usage_viewer", label: "Usage Viewer" },
// 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<TableFilterDef[]>(
+228 -68
View File
@@ -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";
@@ -13,9 +15,10 @@ import {
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";
@@ -23,6 +26,7 @@ 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;
@@ -37,49 +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,
},
{
name: "Agent Network Admin",
value: Role.AgentNetworkAdmin,
icon: AgentNetworkIcon,
},
{
name: "Usage Viewer",
value: Role.UsageViewer,
icon: GaugeIcon,
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 (
<ScrollArea
className={cn(
"overflow-y-auto flex flex-col gap-1 pl-2 py-2 pr-3",
fixedHeight ? "h-[240px]" : "max-h-[320px]",
)}
>
<CommandGroup>
<div className={"grid grid-cols-1 gap-1"}>
{roles.map((item) => (
<CommandItem
key={item.value}
value={item.value}
data-testid={"user-role-selector-item"}
className={"py-1 px-2"}
onSelect={() => onSelect(item.value)}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-start gap-2.5 p-1"}>
<div className={"pt-[3px]"}>
<item.icon size={14} width={14} />
</div>
<div className={"flex flex-col text-sm font-medium"}>
<span className={"text-nb-gray-200 whitespace-nowrap"}>
{useShortNames ? (item.shortName ?? item.name) : item.name}
</span>
<span className={"text-xs font-normal text-nb-gray-400"}>
{item.description}
</span>
</div>
</div>
</CommandItem>
))}
</div>
</CommandGroup>
</ScrollArea>
);
};
export function UserRoleSelector({
onChange,
value,
@@ -137,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<string>(
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 (
<Popover
open={open}
onOpenChange={(isOpen) => {
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);
}}
>
<PopoverTrigger asChild={true}>
@@ -157,19 +310,27 @@ export function UserRoleSelector({
className={"w-full group/user-role-selector"}
data-testid={"user-role-selector"}
>
<div className={"w-full flex justify-between items-center gap-2"}>
<div
className={
"w-full flex justify-between items-center gap-2 min-w-0"
}
>
{selectedRole && (
<div className={"flex items-center gap-2.5"}>
<selectedRole.icon size={14} width={14} />
<div className={"flex flex-col text-sm font-medium"}>
<span className={"text-nb-gray-200 whitespace-nowrap"}>
<div className={"flex items-center gap-2.5 min-w-0"}>
<div className={"shrink-0 flex items-center"}>
<selectedRole.icon size={14} width={14} />
</div>
<div className={"flex flex-col text-sm font-medium min-w-0"}>
{/* Truncate instead of overflowing the button: role names
can be longer than the column they sit in. */}
<span className={"text-nb-gray-200 truncate"}>
{selectedRole?.name}
</span>
</div>
</div>
)}
<div className={"pl-2"}>
<div className={"pl-2 shrink-0"}>
<ChevronsUpDown size={18} className={"shrink-0"} />
</div>
</div>
@@ -180,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}
>
<Command
className={"w-full flex"}
@@ -196,55 +364,47 @@ export function UserRoleSelector({
}}
>
<CommandList className={"w-full"}>
<ScrollArea
className={
"max-h-[380px] overflow-y-auto flex flex-col gap-1 pl-2 py-2 pr-3"
}
>
<CommandGroup>
<div className={"grid grid-cols-1 gap-1"}>
{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 (
<CommandItem
key={item.value}
value={item.value}
data-testid={"user-role-selector-item"}
className={"py-1 px-2"}
onSelect={() => toggle(item.value)}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2.5 p-1"}>
<item.icon size={14} width={14} />
<div
className={
"flex flex-col text-sm font-medium text-nb-gray-200 whitespace-nowrap"
}
>
{item.name}
</div>
</div>
</CommandItem>
);
})}
</div>
</CommandGroup>
</ScrollArea>
{showTabs ? (
<Tabs value={activeTab} onValueChange={setTab}>
<TabsList justify={"start"} className={"px-3"}>
{categories.map((category) => (
<TabsTrigger
key={category.value}
value={category.value}
className={"text-[.8rem] font-normal"}
data-testid={`user-role-selector-tab-${category.value}`}
>
<category.icon
size={14}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
{category.name}
</TabsTrigger>
))}
</TabsList>
{categories.map((category) => (
<TabsContent
key={category.value}
value={category.value}
className={"p-0 my-0 pt-0"}
>
<RoleList
roles={category.roles}
onSelect={toggle}
useShortNames={true}
fixedHeight={true}
/>
</TabsContent>
))}
</Tabs>
) : (
<RoleList
roles={categories.flatMap((category) => category.roles)}
onSelect={toggle}
/>
)}
</CommandList>
</Command>
</PopoverContent>
+16 -5
View File
@@ -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<User>[] = [
{
@@ -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,12 +251,21 @@ export default function UsersTable({
{ value: "admin", label: "Admin" },
{ value: "user", label: "User" },
{ value: "network_admin", label: "Network Admin" },
{ value: "agent_network_admin", label: "Agent Network Admin" },
{ value: "usage_viewer", label: "Usage Viewer" },
// 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<TableFilterDef[]>(