Compare commits

...

2 Commits

Author SHA1 Message Date
Eduard Gert
52fd984912 Add user view to control center (#525)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-07 17:53:55 +01:00
Misha Bragin
83e3159ee4 Configure Identity Providers in the UI (#523)
* Add user creation with password copy

* Add initial identity provider view

* Add IdP logos

* Add IdP id to user

* Add IdP logo to user obj

* Fix okta icon

* Return callback URL when creating an IdP

* Create user for self-hosted

* Clear up password from the state

* Show IdPs and create user when enabled

* Fetch IdPs only when embedded idp is enabled

* Update src/app/(dashboard)/settings/page.tsx

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

* Update src/app/(dashboard)/settings/page.tsx

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

* Update src/modules/settings/IdentityProvidersTab.tsx

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

* Update src/modules/settings/IdentityProviderModal.tsx

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

* Update src/modules/settings/IdentityProvidersTab.tsx

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

* Update src/modules/settings/IdentityProviderModal.tsx

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

* Rename IdentityProvider to SSOIdentityProvider

* Fix build and extract icons

* Fix initial onboarding

* Add icons

* Move name to the top

* Fix setup wizard background color

* Update instance setup ui

* Update instance setup ui

* Use input component

* Move idp label and icons

* Fix setup wizard width

* Add authentik and keycloak

* Add idp hints

* Handle idp permissions

* Consider selfhosted instances when checking if netbird is hosted

* Update redirect

* Add max retries to redirect

* Require new secret when clientid changed

* Add callback URL on the idp creation step

* Add idp activity events

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-01-07 14:43:30 +01:00
56 changed files with 2810 additions and 375 deletions

View File

@@ -2,10 +2,15 @@
import "@xyflow/react/dist/style.css";
import Button from "@components/Button";
import InlineLink from "@components/InlineLink";
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import SquareIcon from "@components/SquareIcon";
import GetStartedTest from "@components/ui/GetStartedTest";
import { SmallBadge } from "@components/ui/SmallBadge";
import useFetchApi from "@utils/api";
import {
Background,
@@ -15,6 +20,8 @@ import {
NodeTypes,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
} from "@xyflow/react";
import { forEach, orderBy, sortBy } from "lodash";
@@ -25,9 +32,23 @@ import {
MessageSquareShareIcon,
NetworkIcon,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PoliciesProvider from "@/contexts/PoliciesProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Group } from "@/interfaces/Group";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import { Policy } from "@/interfaces/Policy";
import { User } from "@/interfaces/User";
import PageContainer from "@/layouts/PageContainer";
import { AccessControlUpdateModal } from "@/modules/access-control/AccessControlModal";
import { FlowSelector, FlowView } from "@/modules/control-center/FlowSelector";
import { NetworkRoutingPeerCount } from "@/modules/control-center/NetworkRoutingPeerCount";
import { ControlCenterCurrentUserBadge } from "@/modules/control-center/user/ControlCenterCurrentUserBadge";
import { EDGE_TYPES } from "@/modules/control-center/utils/edges";
import {
getFirstGroup,
@@ -41,24 +62,6 @@ import {
DEFAULT_MIN_ZOOM,
} from "@/modules/control-center/utils/layouts";
import { NODE_TYPES } from "@/modules/control-center/utils/nodes";
import PeersProvider from "@/contexts/PeersProvider";
import PoliciesProvider from "@/contexts/PoliciesProvider";
import { Group } from "@/interfaces/Group";
import { Network, NetworkResource } from "@/interfaces/Network";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { Policy } from "@/interfaces/Policy";
import PageContainer from "@/layouts/PageContainer";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { AccessControlUpdateModal } from "@/modules/access-control/AccessControlModal";
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
import GetStartedTest from "@components/ui/GetStartedTest";
import SquareIcon from "@components/SquareIcon";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import InlineLink from "@components/InlineLink";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useRouter, useSearchParams } from "next/navigation";
import { SmallBadge } from "@components/ui/SmallBadge";
export default function ControlCenter() {
return (
@@ -71,8 +74,8 @@ export default function ControlCenter() {
}
function ControlCenterView() {
const [nodes, setNodes] = useState<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const reactFlow = useReactFlow();
const [layoutInitialized, setLayoutInitialized] = useState(false);
const [forceLayoutChange, setForceLayoutChange] = useState(false);
@@ -82,6 +85,7 @@ function ControlCenterView() {
const queryTab = queryParams.get("tab");
const initialTab = useMemo(() => {
if (queryTab === "peers") return FlowView.PEERS;
if (queryTab === "users") return FlowView.USERS;
if (queryTab === "groups") return FlowView.GROUPS;
if (queryTab === "networks") return FlowView.NETWORKS;
return FlowView.PEERS;
@@ -99,17 +103,24 @@ function ControlCenterView() {
>("/networks/resources");
const { data: groups, isLoading: isGroupsLoading } =
useFetchApi<Group[]>("/groups");
const { data: users, isLoading: isUsersLoading } = useFetchApi<User[]>(
"/users?service_user=false",
);
const isLoading =
isPoliciesLoading ||
isPeersLoading ||
isNetworksLoading ||
isResourcesLoading ||
isGroupsLoading;
isGroupsLoading ||
isUsersLoading;
const [selectedNetwork, setSelectedNetwork] = useState("");
const [selectedGroup, setSelectedGroup] = useState("");
const [selectedPeer, setSelectedPeer] = useState("");
const [selectedUser, setSelectedUser] = useState("");
const [previousSelectedUser, setPreviousSelectedUser] = useState("");
const [selectedPolicy, setSelectedPolicy] = useState("");
const [selectedDestinationGroup, setSelectedDestinationGroup] = useState("");
@@ -138,14 +149,149 @@ function ControlCenterView() {
const onDestinationGroupSelect = useCallback(
(groupId: string) => {
setLayoutInitialized(false);
if (selectedDestinationGroup == groupId) {
setSelectedDestinationGroup("");
} else {
setSelectedDestinationGroup(groupId);
const isTogglingSameGroup = selectedDestinationGroup === groupId;
const newSelectedGroup = isTogglingSameGroup ? "" : groupId;
setSelectedDestinationGroup(newSelectedGroup);
if (
currentView !== FlowView.PEERS &&
currentView !== FlowView.GROUPS &&
currentView !== FlowView.USERS
) {
setLayoutInitialized(false);
return;
}
const getPeersAndResources = (groupId: string) => {
const resources =
networkResources?.filter((n) => {
const resourceGroupIds =
n.groups?.map((g) => (g as Group)?.id) || [];
return resourceGroupIds.includes(groupId);
}) || [];
const groupPeers =
peers?.filter((p) => {
const peerGroupIds = p.groups?.map((g) => g.id) || [];
return peerGroupIds.includes(groupId);
}) || [];
return { resources, peers: groupPeers };
};
const addExpandedNodes = (groupId: string, baseNodes: Node[]) => {
const { resources, peers } = getPeersAndResources(groupId);
const destinationGroupNode = baseNodes.find(
(node) => node.id === `group-${groupId}`,
);
if (!destinationGroupNode) return [];
const baseX = destinationGroupNode.position.x + 300;
const groupCenterY = destinationGroupNode.position.y;
const nodeSpacing = 80;
const totalNodes = peers.length + resources.length;
const totalHeight = (totalNodes - 1) * nodeSpacing;
const startY = groupCenterY - totalHeight / 2;
const newNodes: Node[] = [];
let currentY = startY;
// Add peer nodes
peers.forEach((peer) => {
newNodes.push({
id: `peer-${peer.id}`,
type:
currentView === FlowView.PEERS ? "expandedGroupPeer" : "peerNode",
data: { peer },
position: { x: baseX, y: currentY },
});
currentY += nodeSpacing;
});
// Add resource nodes
resources.forEach((resource) => {
newNodes.push({
id: `resource-${resource.id}`,
type: "resourceNode",
data: { resource },
position: { x: baseX, y: currentY },
});
currentY += nodeSpacing;
});
return newNodes;
};
const addExpandedEdges = (groupId: string) => {
const { resources, peers } = getPeersAndResources(groupId);
const newEdges: Edge[] = [];
// Add peer edges
peers.forEach((peer) => {
newEdges.push({
id: `group-peer-${groupId}-${peer.id}`,
source: `group-${groupId}`,
target: `peer-${peer.id}`,
type: "simple",
data: { enabled: true },
});
});
// Add resource edges
resources.forEach((resource) => {
newEdges.push({
id: `group-resource-${groupId}-${resource.id}`,
source: `group-${groupId}`,
target: `resource-${resource.id}`,
type: "simple",
data: { enabled: true },
});
});
return newEdges;
};
// Update nodes
setNodes((prevNodes) => {
// Remove previous nodes
const baseNodes = prevNodes.filter(
(node) =>
!node.id.startsWith(`peer-`) && !node.id.startsWith(`resource-`),
);
// If toggling a new group, add its nodes
if (!isTogglingSameGroup) {
const expandedNodes = addExpandedNodes(groupId, baseNodes);
return [...baseNodes, ...expandedNodes];
}
return baseNodes;
});
// Update edges
setEdges((prevEdges) => {
// Remove all previously expanded peer/resource edges
const baseEdges = prevEdges.filter(
(edge) =>
!edge.id.includes(`group-peer-`) &&
!edge.id.includes(`group-resource-`),
);
// If expanding a new group, add its edges
if (!isTogglingSameGroup) {
const expandedEdges = addExpandedEdges(groupId);
return [...baseEdges, ...expandedEdges];
}
return baseEdges;
});
},
[selectedDestinationGroup],
[
selectedDestinationGroup,
currentView,
setNodes,
setEdges,
networkResources,
peers,
],
);
const applySingleGroupView = (groupId: string) => {
@@ -211,7 +357,6 @@ function ControlCenterView() {
type: "destinationGroupNode",
data: {
group: destination,
enabled,
},
position: { x: 0, y: 0 },
});
@@ -235,7 +380,7 @@ function ControlCenterView() {
allNodes.push({
id: peerNodeId,
type: "peerNode",
data: { peer, enabled },
data: { peer },
position: { x: 0, y: 0 },
});
} else {
@@ -281,7 +426,7 @@ function ControlCenterView() {
allNodes.push({
id: resourceNodeId,
type: "resourceNode",
data: { resource, enabled },
data: { resource },
position: { x: 0, y: 0 },
});
} else {
@@ -356,6 +501,9 @@ function ControlCenterView() {
});
}
});
// Add destination resource nodes
addDestinationResourceNodes(policy, allNodes, allEdges);
});
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "group", {
@@ -645,7 +793,6 @@ function ControlCenterView() {
if (!groups || isGroupsLoading) return;
if (!networks || isNetworksLoading) return;
if (!networkResources || isResourcesLoading) return;
if (layoutInitialized) return;
const allNodes: Node[] = [];
const allEdges: Edge[] = [];
@@ -704,7 +851,6 @@ function ControlCenterView() {
type: "destinationGroupNode",
data: {
group: destination,
enabled,
},
position: { x: 0, y: 0 },
});
@@ -848,6 +994,9 @@ function ControlCenterView() {
});
}
});
// Add destination resource nodes
addDestinationResourceNodes(policy, allNodes, allEdges);
});
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "peer", {
@@ -857,13 +1006,290 @@ function ControlCenterView() {
});
};
const addDestinationResourceNodes = (
policy: Policy,
nodes: Node[],
edges: Edge[],
) => {
const destinationPolicyResource = policy?.rules?.[0].destinationResource;
const enabled = policy.enabled;
if (destinationPolicyResource) {
const type = destinationPolicyResource.type;
const peer = peers?.find((p) => p.id === destinationPolicyResource.id);
const resource = networkResources?.find(
(r) => r.id === destinationPolicyResource.id,
);
const nodeId = `destination-resource-${destinationPolicyResource.id}`;
const nodeExists = nodes.some((n) => n.id === nodeId);
if (!nodeExists) {
if (type === "peer" && peer) {
nodes.push({
id: nodeId,
type: "destinationResourceNode",
data: {
peer: peer,
enabled,
className: "pl-3",
},
position: { x: 0, y: 0 },
});
} else if (resource) {
nodes.push({
id: nodeId,
type: "destinationResourceNode",
data: {
resource: resource,
enabled,
className: "pl-3",
},
position: { x: 0, y: 0 },
});
}
} else {
nodes.forEach((n) => {
if (n.id === nodeId) {
n.data = {
...n.data,
enabled,
};
}
});
}
const edgeExists = edges.some(
(e) => e.id === `policy-dest-resource-${policy.id}-${nodeId}`,
);
if (!edgeExists) {
edges.push({
id: `policy-dest-resource-${policy.id}-${nodeId}`,
source: `policy-${policy.id}`,
target: nodeId,
type: "in",
data: { enabled, type: "bezier" },
});
}
}
};
const applyUserView = (userId: string) => {
if (!policies || isLoading) return;
if (!groups || isGroupsLoading) return;
if (!networks || isNetworksLoading) return;
if (!networkResources || isResourcesLoading) return;
const allNodes: Node[] = [];
const allEdges: Edge[] = [];
// Get all peers for this user
const userPeers = peers?.filter((p) => p.user_id === userId) || [];
if (userPeers.length === 0) {
return applyD3HierarchicalLayout([], [], 400, 120, "user", {
policy: { width: 500, spacing: 60 },
destinationGroup: { width: 1000, spacing: 100 },
peersAndResources: { width: 1400, spacing: 80 },
});
}
// Add peer nodes
userPeers.forEach((peer, index) => {
allNodes.push({
id: `source-peer-${peer.id}`,
type: "sourcePeerNode",
data: {
peer,
enabled: true,
onClick: () => {
setPreviousSelectedUser(userId);
forceSinglePeerView(peer.id || "", userId);
},
},
position: { x: 0, y: 0 },
});
allEdges.push({
id: `user-peer-${userId}-${peer.id}`,
source: `select-user-node`,
target: `source-peer-${peer.id}`,
type: "simple",
data: { enabled: true },
});
});
const allUserGroups = [
...new Set(userPeers.flatMap((p) => p.groups?.map((g) => g.id) || [])),
];
const userPolicies = sortBy(
policies?.filter((p) => {
const rule = p.rules?.[0];
if (!rule) return false;
const sources = rule.sources as Group[];
return sources?.some((d) => allUserGroups.includes(d.id));
}),
"enabled",
"desc",
);
// Add policies and their connections
userPolicies?.forEach((policy, policyIndex) => {
const enabled = policy.enabled;
const policyNodeId = `policy-${policy.id}`;
allNodes.push({
id: policyNodeId,
type: "policyNode",
data: { policy },
position: { x: 600, y: policyIndex * 120 },
});
// Add peer to policy edges
const rule = policy.rules?.[0];
const sourcesIds = (rule?.sources as Group[])?.map((g) => g.id) || [];
userPeers.forEach((peer) => {
const peerGroupIds = peer.groups?.map((g) => g.id) || [];
const hasSharedGroup = sourcesIds.some((sourceId) =>
peerGroupIds.includes(sourceId),
);
if (hasSharedGroup) {
allEdges.push({
id: `peer-policy-${peer.id}-${policy.id}`,
source: `source-peer-${peer.id}`,
target: policyNodeId,
type: "in",
data: { enabled, type: "bezier" },
});
}
});
// Add destination groups
const destinations = (rule?.destinations as Group[]) || [];
destinations.forEach((destination, destIndex) => {
const destinationNodeId = `group-${destination.id}`;
const destinationNodeExists = allNodes.some(
(n) => n.id === destinationNodeId,
);
if (!destinationNodeExists) {
allNodes.push({
id: destinationNodeId,
type: "destinationGroupNode",
data: {
group: destination,
},
position: { x: 900, y: policyIndex * 120 + destIndex * 60 },
});
}
const destinationEdgeExists = allEdges.some(
(e) => e.id === `policy-group-${policy.id}-${destination.id}`,
);
if (!destinationEdgeExists) {
allEdges.push({
id: `policy-group-${policy.id}-${destination.id}`,
source: policyNodeId,
target: destinationNodeId,
type: "in",
data: { enabled, type: "bezier" },
});
}
// Add expanded destination group content if selected
if (selectedDestinationGroup === destination.id) {
const resources = networkResources.filter((n) => {
const resourceGroupIds =
n.groups?.map((g) => (g as Group)?.id) || [];
return resourceGroupIds.includes(destination.id);
});
const destinationPeers = peers?.filter((p) => {
const peerGroupIds = p.groups?.map((g) => g.id) || [];
return peerGroupIds.includes(destination.id);
});
// Add peer nodes
destinationPeers?.forEach((peer, peerIndex) => {
const peerNodeId = `dest-peer-${peer.id}`;
const peerNodeExists = allNodes.some((n) => n.id === peerNodeId);
if (!peerNodeExists) {
allNodes.push({
id: peerNodeId,
type: "peerNode",
data: { peer },
position: { x: 1200, y: policyIndex * 120 + peerIndex * 80 },
});
}
const peerEdgeExists = allEdges.some(
(e) => e.id === `group-peer-${destination.id}-${peer.id}`,
);
if (!peerEdgeExists) {
allEdges.push({
id: `group-peer-${destination.id}-${peer.id}`,
source: destinationNodeId,
target: peerNodeId,
type: "simple",
data: { enabled },
});
}
});
// Add resource nodes
resources.forEach((resource, resourceIndex) => {
const resourceNodeId = `resource-${resource.id}`;
const resourceNodeExists = allNodes.some(
(n) => n.id === resourceNodeId,
);
if (!resourceNodeExists) {
allNodes.push({
id: resourceNodeId,
type: "resourceNode",
data: { resource },
position: {
x: 1200,
y:
policyIndex * 120 +
(destinationPeers?.length || 0) * 80 +
resourceIndex * 80,
},
});
}
const resourceEdgeExists = allEdges.some(
(e) => e.id === `group-resource-${destination.id}-${resource.id}`,
);
if (!resourceEdgeExists) {
allEdges.push({
id: `group-resource-${destination.id}-${resource.id}`,
source: destinationNodeId,
target: resourceNodeId,
type: "simple",
data: { enabled },
});
}
});
}
});
// Add destination resource nodes
addDestinationResourceNodes(policy, allNodes, allEdges);
});
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "user", {
policy: { width: 500, spacing: 60 },
destinationGroup: { width: 1000, spacing: 100 },
peersAndResources: { width: 1400, spacing: 80 },
});
};
const fitView = (newNodes?: Node[]) => {
window.requestAnimationFrame(() =>
reactFlow.fitView({
nodes: newNodes ?? nodes,
padding: 0.1,
duration: 750,
maxZoom: DEFAULT_MAX_ZOOM,
maxZoom: 0.8,
minZoom: DEFAULT_MIN_ZOOM,
}),
);
@@ -903,6 +1329,76 @@ function ControlCenterView() {
});
};
const handlePeerChange = (newPeerId: string) => {
setNodes((prev) => {
const shouldRecalculate = selectedPeer !== newPeerId;
shouldRecalculate && setSelectedPeer(newPeerId);
let selectPeerNode;
const previousNodes = prev.map((node) => {
if (node.id === `select-peer-node`) {
selectPeerNode = shouldRecalculate
? {
...node,
data: {
...node.data,
currentPeer: newPeerId,
},
}
: node;
return selectPeerNode;
}
return node;
});
const result = applyPeerView(newPeerId);
if (result && selectPeerNode) {
let nodesWithCurrentPeer = result.updatedNodes;
nodesWithCurrentPeer.push(selectPeerNode);
setEdges(result.updatedEdges);
setLayoutInitialized(true);
shouldRecalculate && fitView(nodesWithCurrentPeer);
return nodesWithCurrentPeer;
} else {
return previousNodes;
}
});
};
const handleUserChange = (newUserId: string) => {
setNodes((prev) => {
const shouldRecalculate = selectedUser !== newUserId;
shouldRecalculate && setSelectedUser(newUserId);
let selectUserNode;
const previousNodes = prev.map((node) => {
if (node.id === `select-user-node`) {
selectUserNode = shouldRecalculate
? {
...node,
data: {
...node.data,
currentUser: newUserId,
},
}
: node;
return selectUserNode;
}
return node;
});
const result = applyUserView(newUserId);
if (result && selectUserNode) {
let nodesWithCurrentUser = result.updatedNodes;
nodesWithCurrentUser.push(selectUserNode);
setEdges(result.updatedEdges);
setLayoutInitialized(true);
shouldRecalculate && fitView(nodesWithCurrentUser);
return nodesWithCurrentUser;
} else {
return previousNodes;
}
});
};
const forceSingleGroupView = (groupId: string) => {
setSelectedGroup(groupId);
setSelectedNetwork("");
@@ -928,13 +1424,70 @@ function ControlCenterView() {
}
};
const forceSingleUserView = (userId: string) => {
setSelectedPeer("");
setSelectedUser("");
setPreviousSelectedUser("");
setCurrentView(FlowView.USERS);
const selectUserNode = {
id: `select-user-node`,
type: "selectUserNode",
position: { x: -550, y: 0 },
data: {
currentUser: userId,
onUserChange: handleUserChange,
},
};
setNodes([selectUserNode]);
const result = applyUserView(userId);
if (result) {
let nodesWithUser = result.updatedNodes;
nodesWithUser.push(selectUserNode);
setEdges(result.updatedEdges);
setNodes(nodesWithUser);
setLayoutInitialized(true);
fitView(nodesWithUser);
}
};
const forceSinglePeerView = (peerId: string, userId?: string) => {
setSelectedPeer(peerId);
setSelectedNetwork("");
setSelectedUser("");
setCurrentView(FlowView.PEERS);
const selectPeerNode = {
id: `select-peer-node`,
type: "selectPeerNode",
position: { x: 0, y: 0 },
data: {
currentPeer: peerId,
onPeerChange: handlePeerChange,
userId: userId,
placeholder: "Search peers of user...",
},
};
setNodes([selectPeerNode]);
const result = applyPeerView(peerId);
if (result) {
let nodesWithCurrentPeer = result.updatedNodes;
nodesWithCurrentPeer.push(selectPeerNode);
setEdges(result.updatedEdges);
setNodes(nodesWithCurrentPeer);
setLayoutInitialized(true);
fitView(nodesWithCurrentPeer);
}
};
useEffect(() => {
if (isLoading) return;
if (layoutInitialized) return;
switch (currentView) {
case FlowView.PEERS:
if (peers?.length === 0) {
if (!peers || peers.length === 0) {
setEdges([]);
setNodes([]);
setLayoutInitialized(true);
@@ -942,41 +1495,6 @@ function ControlCenterView() {
return;
}
const handlePeerChange = (newPeerId: string) => {
setNodes((prev) => {
const shouldRecalculate = selectedPeer !== newPeerId;
shouldRecalculate && setSelectedPeer(newPeerId);
let selectPeerNode;
const previousNodes = prev.map((node) => {
if (node.id === `select-peer-node`) {
selectPeerNode = shouldRecalculate
? {
...node,
data: {
...node.data,
currentPeer: newPeerId,
},
}
: node;
return selectPeerNode;
}
return node;
});
const result = applyPeerView(newPeerId);
if (result && selectPeerNode) {
let nodesWithCurrentPeer = result.updatedNodes;
nodesWithCurrentPeer.push(selectPeerNode);
setEdges(result.updatedEdges);
setLayoutInitialized(true);
shouldRecalculate && fitView(nodesWithCurrentPeer);
return nodesWithCurrentPeer;
} else {
return previousNodes;
}
});
};
if (selectedPeer === "") {
const userPeer = peers?.find((p) => p.user_id === loggedInUser?.id);
const firstPeer = userPeer ?? peers?.[0];
@@ -998,6 +1516,50 @@ function ControlCenterView() {
handlePeerChange(selectedPeer);
}
break;
case FlowView.USERS:
if (!users || users.length === 0) {
setEdges([]);
setNodes([]);
setLayoutInitialized(true);
fitView([]);
return;
}
if (selectedUser === "") {
let initialUser = users?.find((u) => u.id === loggedInUser?.id);
if (
!initialUser ||
!peers?.some((p) => p.user_id === initialUser?.id)
) {
initialUser = users?.find(
(u) => peers?.some((p) => p.user_id === u.id),
);
}
if (!initialUser) {
initialUser = users?.[0];
}
const initialUserId = initialUser?.id ?? "";
setNodes([
{
id: `select-user-node`,
type: "selectUserNode",
position: { x: -550, y: 0 },
data: {
currentUser: initialUserId,
onUserChange: handleUserChange,
},
},
]);
if (initialUserId !== "") handleUserChange(initialUserId);
} else {
resetView();
handleUserChange(selectedUser);
}
break;
case FlowView.GROUPS:
if (selectedGroup === "") {
@@ -1023,7 +1585,7 @@ function ControlCenterView() {
}
break;
case FlowView.NETWORKS:
if (networks?.length === 0) {
if (!networks || networks.length === 0) {
setEdges([]);
setNodes([]);
setLayoutInitialized(true);
@@ -1051,6 +1613,7 @@ function ControlCenterView() {
selectedNetwork,
selectedPeer,
selectedGroup,
selectedUser,
isLoading,
layoutInitialized,
]);
@@ -1077,6 +1640,7 @@ function ControlCenterView() {
setSelectedPeer("");
setSelectedGroup("");
setSelectedNetwork("");
setSelectedUser("");
setCurrentView(view);
try {
@@ -1108,7 +1672,11 @@ function ControlCenterView() {
if (networkId && currentView === FlowView.NETWORKS) {
onNetworkSelect(networkId);
}
if (currentView === FlowView.PEERS || currentView === FlowView.GROUPS) {
if (
currentView === FlowView.PEERS ||
currentView === FlowView.GROUPS ||
currentView === FlowView.USERS
) {
groupId && onGroupSelect(groupId);
destinationGroupId && onDestinationGroupSelect(destinationGroupId);
}
@@ -1206,10 +1774,6 @@ function ControlCenterView() {
<div className={"absolute left-0 top-0 z-10"}>
<div className={"flex justify-between px-6 py-4 text-sm w-full"}>
<div className={"flex gap-4"}>
{selectedNetwork === "" && (
<FlowSelector value={currentView} onChange={onViewChange} />
)}
{selectedNetwork !== "" && (
<Button
variant={"secondary"}
@@ -1221,6 +1785,28 @@ function ControlCenterView() {
</Button>
)}
{previousSelectedUser !== "" && (
<>
<Button
variant={"secondary"}
size={"xs"}
className={"!bg-nb-gray-930"}
onClick={() => {
forceSingleUserView(previousSelectedUser);
}}
>
<ArrowLeftIcon size={14} />
</Button>
<ControlCenterCurrentUserBadge
userId={previousSelectedUser}
/>
</>
)}
{selectedNetwork === "" && previousSelectedUser === "" && (
<FlowSelector value={currentView} onChange={onViewChange} />
)}
{currentView === "networks" && (
<div className={"w-64"}>
<SelectDropdown
@@ -1270,6 +1856,8 @@ function ControlCenterView() {
<ReactFlow
edges={edges}
nodes={nodes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
proOptions={{
hideAttribution: true,
}}

View File

@@ -4,6 +4,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import {
AlertOctagonIcon,
FingerprintIcon,
FolderGit2Icon,
LockIcon,
MonitorSmartphoneIcon,
@@ -19,6 +20,7 @@ import { useAccount } from "@/modules/account/useAccount";
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
import IdentityProvidersTab from "@/modules/settings/IdentityProvidersTab";
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
import PermissionsTab from "@/modules/settings/PermissionsTab";
import GroupsSettings from "@/modules/settings/GroupsSettings";
@@ -53,6 +55,13 @@ export default function NetBirdSettings() {
<ShieldIcon size={14} />
Authentication
</VerticalTabs.Trigger>
{account?.settings?.embedded_idp_enabled &&
permission.identity_providers.read && (
<VerticalTabs.Trigger value="identity-providers">
<FingerprintIcon size={14} />
Identity Providers
</VerticalTabs.Trigger>
)}
<VerticalTabs.Trigger value="groups">
<FolderGit2Icon size={14} />
Groups
@@ -80,6 +89,8 @@ export default function NetBirdSettings() {
>
<div className={"border-l border-nb-gray-930 w-full"}>
{account && <AuthenticationTab account={account} />}
{account?.settings?.embedded_idp_enabled &&
permission.identity_providers.read && <IdentityProvidersTab />}
{account && <PermissionsTab account={account} />}
{account && <GroupsSettings account={account} />}
{account && <NetworkSettingsTab account={account} />}

View File

@@ -36,6 +36,6 @@ export default function NotFound() {
const Redirect = ({ url, queryParams }: Props) => {
const params = queryParams && `?${queryParams}`;
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`);
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`, true);
return <FullScreenLoading />;
};

View File

@@ -37,6 +37,6 @@ export default function Home() {
const Redirect = ({ url, queryParams }: Props) => {
const params = queryParams && `?${queryParams}`;
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`);
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`, true);
return <FullScreenLoading />;
};

8
src/app/setup/layout.tsx Normal file
View File

@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Instance Setup - ${globalMetaTitle}`,
};
export default BlankLayout;

7
src/app/setup/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
"use client";
import InstanceSetupWizard from "@/modules/instance-setup/InstanceSetupWizard";
export default function SetupPage() {
return <InstanceSetupWizard />;
}

View File

@@ -0,0 +1,28 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function AuthentikIcon(props: Readonly<IconProps>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="-0.03 59.9 512.03 392.1"
{...iconProperties(props)}
>
<path
d="M279.9 141h17.9v51.2h-17.9zm46.6-2.2h17.9v40h-17.9zM65.3 197.3c-24 0-46 13.2-57.4 34.3h30.4c13.5-11.6 33-15 47.1 0h32.2c-12.6-17.1-31.4-34.3-52.3-34.3"
fill="#fd4b2d"
/>
<path
d="M108.7 262.4C66.8 350-6.6 275.3 38.3 231.5H7.9C-15.9 273 17 329 65.3 327.8c37.4 0 68.2-55.5 68.2-65.3 0-4.3-6-17.6-16-31H85.4c10.7 9.7 20 23.7 23.3 30.9m1.1-2.6"
fill="#fd4b2d"
/>
<path
d="M512 140.3v231.3c0 44.3-36.1 80.4-80.4 80.4h-34.1v-78.8h-163V452h-34.1c-44.4 0-80.4-36.1-80.4-80.4v-72.8h258.4v-139H253.6V238H119.9v-97.6c0-3.1.2-6.2.5-9.2.4-3.7 1.1-7.3 2-10.8.3-1.1.6-2.3 1-3.4.1-.3.2-.6.3-.8.2-.6.4-1.1.5-1.7.2-.5.4-1.1.6-1.7s.5-1.2.7-1.8.5-1.2.8-1.8c2-4.7 4.4-9.3 7.3-13.6l.1-.1c.7-1.1 1.5-2.1 2.3-3.2.7-.9 1.3-1.7 2-2.6.8-.9 1.6-1.9 2.4-2.8s1.6-1.8 2.4-2.6l.1-.1c.4-.5.9-.9 1.4-1.4 3-2.9 6.2-5.6 9.6-8 .9-.7 1.9-1.3 2.8-1.9 1.1-.7 2.2-1.4 3.3-2 2.1-1.2 4.2-2.4 6.5-3.4.7-.3 1.4-.7 2.1-1 3.1-1.3 6.2-2.5 9.4-3.4 1.2-.4 2.5-.7 3.7-1 .6-.2 1.2-.3 1.8-.4 3.6-.8 7.2-1.3 10.9-1.6l1.6-.1h.8c1.2-.1 2.4-.1 3.7-.1h231.3c1.2 0 2.5 0 3.7.1h.8l1.6.1c3.7.3 7.3.8 10.9 1.6.6.1 1.2.3 1.8.4 1.3.3 2.5.6 3.7 1 3.2.9 6.3 2.1 9.4 3.4.7.3 1.4.6 2.1 1 2.2 1 4.4 2.2 6.5 3.4 1.1.7 2.2 1.3 3.3 2 1 .6 1.9 1.3 2.8 1.9 3.9 2.8 7.6 6 11 9.4.8.8 1.7 1.7 2.4 2.6.8.9 1.6 1.9 2.4 2.8.7.8 1.3 1.7 2 2.6.8 1.1 1.5 2.1 2.3 3.2l.1.1c2.9 4.3 5.3 8.8 7.3 13.6.2.6.5 1.2.8 1.8.2.6.5 1.2.7 1.8.2.5.4 1.1.6 1.7s.4 1.1.5 1.7c.1.3.2.6.3.8.3 1.1.7 2.3 1 3.4.9 3.6 1.6 7.2 2 10.8 0 3.1.2 6.1.2 9.2"
fill="#fd4b2d"
/>
<path
d="M498.3 95.5H133.5c14.9-22.2 40-35.6 66.7-35.6h231.3c26.9 0 51.9 13.4 66.8 35.6m13.2 35.6H120.4c1.4-12.8 6-25 13.1-35.6h364.8c7.2 10.6 11.7 22.9 13.2 35.6m.5 9.2v26.4H378.3v-6.9H253.6v6.9H119.9v-26.4c0-3.1.2-6.2.5-9.2h391.1c.3 3.1.5 6.1.5 9.2M119.9 166.7h133.7v35.6H119.9zm258.4 0H512v35.6H378.3zm-258.4 35.6h133.7v35.6H119.9zm258.4 0H512v35.6H378.3z"
fill="#fd4b2d"
/>
</svg>
);
}

View File

@@ -0,0 +1,30 @@
import { SSOIdentityProviderType } from "@/interfaces/IdentityProvider";
import React from "react";
import GoogleIcon from "@/assets/icons/GoogleIcon";
import MicrosoftIcon from "@/assets/icons/MicrosoftIcon";
import EntraIcon from "@/assets/icons/EntraIcon";
import OktaIcon from "@/assets/icons/OktaIcon";
import PocketIdIcon from "@/assets/icons/PocketIdIcon";
import ZitadelIcon from "@/assets/icons/ZitadelIcon";
import AuthentikIcon from "@/assets/icons/AuthentikIcon";
import KeycloakIcon from "@/assets/icons/KeycloakIcon";
import { KeyRound } from "lucide-react";
export const idpIcon = (
type: SSOIdentityProviderType,
size: number = 16,
): React.ReactNode => {
const icons: Record<SSOIdentityProviderType, React.ReactNode> = {
google: <GoogleIcon size={size} />,
microsoft: <MicrosoftIcon size={size} />,
entra: <EntraIcon size={size} />,
okta: <OktaIcon size={size} className="text-nb-gray-300" />,
pocketid: <PocketIdIcon size={size} />,
zitadel: <ZitadelIcon size={size} />,
authentik: <AuthentikIcon size={size} />,
keycloak: <KeycloakIcon size={size} />,
oidc: <KeyRound size={size} className="text-nb-gray-400" />,
};
return icons[type];
};

View File

@@ -0,0 +1,88 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function KeycloakIcon(props: Readonly<IconProps>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
{...iconProperties(props)}
>
<g transform="translate(.714 .07)">
<path
d="M432.9 149.2c-1.4 0-2.7-.7-3.4-2L370.1 44.1c-.7-1.2-2-2-3.5-2H124.2c-1.4 0-2.7.7-3.4 2L58.9 150.9l23.9 34.9c-.7 1.2-6.2 24-5.5 25.2L58.9 360.9l61.9 106.9c.7 1.2 2 2 3.4 2h242.4c1.4 0 2.7-.7 3.5-2l59.4-103.2c.7-1.2 2-2 3.4-2h73.8c2.4 0 4.4-2 4.4-4.4V153.6c0-2.4-2-4.4-4.4-4.4z"
fill="#4d4d4d"
/>
<path d="M72.7 245.3 6.4 269.4l-6.6-11.3c-.7-1.2-.7-2.7 0-3.9l30-52z" fill="#e1e1e1" />
<path d="M511.3 258.3V309l-43.7-44.5z" fill="#c8c8c8" />
<path
d="m467.5 264.5 43.7 44.5v49.6c0 2.4-2 4.4-4.4 4.4H456z"
fill="#c2c2c2"
/>
<path d="M467.5 264.5 456 362.9h-61.2l-18.5-44.7z" fill="#c7c7c7" />
<path d="M511.3 211.2v47l-43.7 6.2z" fill="#cecece" />
<path
d="M511.3 153.6v57.6l-43.7 53.2-33.1-115.3h72.2c2.4-.1 4.5 1.8 4.6 4.3z"
fill="#d3d3d3"
/>
<path d="M394.8 362.9h-32.3l-8.4-12 22.1-32.7z" fill="#c6c6c6" />
<path d="m467.5 264.5-121.1-51.2 63.7-64.1h24.4z" fill="#d5d5d5" />
<path d="m346.5 213.3 29.8 105 91.2-53.8z" fill="#d0d0d0" />
<path d="m353.8 362.9.4-12 8.4 12z" fill="#bfbfbf" />
<path d="m410.1 149.2-63.7 64.1-11.4-57.4 24.6-6.8h50.5z" fill="#d9d9d9" />
<path d="m346.5 213.3-147 33.9 154.7 103.7z" fill="#d4d4d4" />
<path d="m346.5 213.3 7.7 137.6 22.1-32.7z" fill="#d0d0d0" />
<path d="m335 155.9-135.5 91.2 147-33.9z" fill="#d9d9d9" />
<path d="m199.5 247.2-63.7 115.7H99.6L72.7 245.3z" fill="#d8d8d8" />
<path
d="m134.3 149.2-61.5 96.1L57.3 155l2.2-3.8c.7-1.2 2-1.9 3.4-1.9z"
fill="#e2e2e2"
/>
<path
d="M99.6 362.9H62.7c-1.4 0-2.8-.8-3.5-2L6.4 269.4l66.4-24.1z"
fill="#d8d8d8"
/>
<path d="M29.9 202.1 57.1 155l15.7 90.3z" fill="#e4e4e4" />
<path d="m335 155.9-40.8-6.8H159.4l40.1 98z" fill="#dedede" />
<path d="m199.5 247.2-40.1-98h-25.1l-61.5 96.1z" fill="#dedede" />
<path d="M324.7 362.9h29.1l.4-12z" fill="#c5c5c5" />
<path d="M266.7 362.9h58l29.5-12-154.7-103.7 27.9 115.7z" fill="#d0d0d0" />
<path d="m227.4 362.9-27.9-115.7-63.7 115.7z" fill="#d1d1d1" />
<path d="m335.4 149.2-.4 6.8 24.6-6.8z" fill="#ddd" />
<path d="m335 155.9-3.8-6.8h-37z" fill="#e3e3e3" />
<path d="m335 155.9.4-6.8h-4.2z" fill="#e2e2e2" />
<path
d="m223.9 151-59.7 103.4c-.3.5-.4 1.1-.4 1.7h-41.7l82-142q.75.45 1.2 1.2l18.6 32.3c.5 1.1.5 2.4 0 3.4"
fill="#00b8e3"
/>
<path
d="M223.8 364.9 205.3 397q-.45.75-1.2 1.2l-82-142.2h41.7c0 .6.1 1.1.4 1.6l59.6 103.2c.8 1.2.9 2.9 0 4.1"
fill="#33c6e9"
/>
<path
d="m204 114.2-82 141.9-20.6 35.6-19.6-34c-.3-.5-.4-1-.4-1.6s.1-1.2.4-1.7l19.9-34.4 60.4-104.5c.6-1.1 1.8-1.8 3-1.8h37.2c.6 0 1.2.2 1.7.5"
fill="#008aaa"
/>
<path
d="M204 398.2c-.5.3-1.1.5-1.8.5h-37.1c-1.3 0-2.4-.7-3-1.8l-55.2-95.6-5.5-9.5 20.6-35.6z"
fill="#00b8e3"
/>
<path
d="m368.9 256.1-82 142q-.75-.45-1.2-1.2L267 364.7c-.5-1-.5-2.3 0-3.3L326.7 258c.3-.5.5-1.2.5-1.8z"
fill="#008aaa"
/>
<path
d="M409.4 256.1c0 .6-.2 1.3-.5 1.8l-80.3 139.3c-.6 1-1.8 1.7-3 1.6h-37c-.6 0-1.2-.2-1.8-.5L368.9 256l20.6-35.6 19.5 33.8c.3.7.4 1.3.4 1.9"
fill="#00b8e3"
/>
<path
d="M368.9 256.1h-41.7c0-.6-.2-1.2-.5-1.8L267 151.2c-.6-1.1-.6-2.5 0-3.6l18.6-32.2q.45-.75 1.2-1.2z"
fill="#00b8e3"
/>
<path
d="m389.4 220.5-20.6 35.6-82-142c.6-.3 1.2-.5 1.8-.5h37.1c1.2 0 2.3.6 3 1.6z"
fill="#33c6e9"
/>
</g>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function MicrosoftIcon(props: Readonly<IconProps>) {
return (
<svg
viewBox="0 0 221 221"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path fill="#F1511B" d="M104.868 104.868H0V0h104.868z" />
<path fill="#80CC28" d="M220.654 104.868H115.788V0h104.866z" />
<path fill="#00ADEF" d="M104.865 220.695H0V115.828h104.865z" />
<path fill="#FBBC09" d="M220.654 220.695H115.788V115.828h104.866z" />
</svg>
);
}

View File

@@ -0,0 +1,17 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function PocketIdIcon(props: Readonly<IconProps>) {
return (
<svg
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<circle cx="256" cy="256" r="256" fill="#fff" />
<path
d="M268.6 102.4c64.4 0 116.8 52.4 116.8 116.7 0 25.3-8 49.4-23 69.6-14.8 19.9-35 34.3-58.4 41.7l-6.5 2-15.5-76.2 4.3-2c14-6.7 23-21.1 23-36.6 0-22.4-18.2-40.6-40.6-40.6S228 195.2 228 217.6c0 15.5 9 29.8 23 36.6l4.2 2-25 153.4h-69.5V102.4z"
fill="#191919"
/>
</svg>
);
}

View File

@@ -0,0 +1,32 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function ZitadelIcon(props: Readonly<IconProps>) {
return (
<svg
viewBox="0 0 80 79"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<defs>
<linearGradient
id="zitadel-grad"
x1="3.86"
x2="76.88"
y1="47.89"
y2="47.89"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF8F00" />
<stop offset="1" stopColor="#FE00FF" />
</linearGradient>
</defs>
<path
fill="url(#zitadel-grad)"
fillRule="evenodd"
d="M17.12 39.17l1.42 5.32-6.68 6.68 9.12 2.44 1.43 5.32-19.77-5.3L17.12 39.17zM58.82 22.41l-5.32-1.43-2.44-9.12-6.68 6.68-5.32-1.43 14.47-14.47 5.3 19.77zM52.65 67.11l3.89-3.89 9.12 2.44-2.44-9.12 3.9-3.9 5.29 19.77-19.76-5.3zM36.43 69.54l-1.18-12.07 8.23 2.21-7.05 9.86zM23 23.84l5.02 11.04 6.02-6.02L23 23.84zM69.32 36.2l-12.07-1.18 2.2 8.23 9.87-7.05z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -7,7 +7,6 @@ import {
} from "@axa-fr/react-oidc";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRedirect } from "@hooks/useRedirect";
import loadConfig, { buildExtras } from "@utils/config";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
@@ -75,8 +74,7 @@ export default function OIDCProvider({ children }: Props) {
const withCustomHistory = () => {
return {
replaceState: (url: any) => {
router.replace(url);
window.dispatchEvent(new Event("popstate"));
window?.location?.replace(url);
},
};
};
@@ -105,16 +103,17 @@ export default function OIDCProvider({ children }: Props) {
// We bypass authentication for pages that do not require auth.
// E.g., when we just want to show installation steps for public.
if (path === "/install") return children;
// Or the instance setup wizard for first-time setup.
if (path === "/install" || path === "/setup") return children;
return mounted && providerConfig ? (
<OidcProvider
configuration={providerConfig}
//withCustomHistory={withCustomHistory}
withCustomHistory={withCustomHistory}
authenticatingComponent={FullScreenLoading}
authenticatingErrorComponent={OIDCError}
loadingComponent={FullScreenLoading}
callbackSuccessComponent={CallBackSuccess}
callbackSuccessComponent={FullScreenLoading}
onEvent={onEvent}
onSessionLost={() => void 0}
//sessionLostComponent={SessionLost}
@@ -125,11 +124,3 @@ export default function OIDCProvider({ children }: Props) {
<FullScreenLoading />
);
}
const CallBackSuccess = () => {
const params = useSearchParams();
const errorParam = params.get("error");
const currentPath = usePathname();
useRedirect(currentPath, true, !errorParam);
return <FullScreenLoading />;
};

View File

@@ -34,7 +34,7 @@ export const buttonVariants = cva(
secondary: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910",
],
secondaryLighter: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",

View File

@@ -27,6 +27,8 @@ export const linkVariants = cva(
default: "text-netbird hover:underline font-normal",
faded: "text-nb-gray-400 hover:text-nb-gray-300 hover:underline",
white: "text-nb-gray-100 hover:text-white hover:underline",
dashed:
"text-nb-gray-100/90 underline font-normal decoration-dashed hover:text-white",
},
},
},

View File

@@ -2,8 +2,9 @@ import FullTooltip from "@components/FullTooltip";
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { cva, VariantProps } from "class-variance-authority";
import { AlertCircle } from "lucide-react";
import { AlertCircle, Eye, EyeOff } from "lucide-react";
import * as React from "react";
import { useState } from "react";
type InputVariants = VariantProps<typeof inputVariants>;
@@ -18,6 +19,7 @@ export interface InputProps
errorTooltip?: boolean;
errorTooltipPosition?: "top" | "top-right";
prefixClassName?: string;
showPasswordToggle?: boolean;
}
const inputVariants = cva("", {
@@ -61,10 +63,29 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
errorTooltipPosition = "top",
variant = "default",
prefixClassName,
showPasswordToggle = false,
...props
},
ref,
) => {
const [showPassword, setShowPassword] = useState(false);
const isPasswordType = type === "password";
const inputType = isPasswordType && showPassword ? "text" : type;
const passwordToggle =
isPasswordType && showPasswordToggle ? (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={"hover:text-white transition-all"}
aria-label={"Toggle password visibility"}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
) : null;
const suffix = passwordToggle || customSuffix;
return (
<>
<div className={cn("flex relative h-[42px]", maxWidthClass)}>
@@ -94,7 +115,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</div>
<input
type={type}
type={inputType}
ref={ref}
{...props}
className={cn(
@@ -103,7 +124,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
"file:border-0",
"focus-visible:ring-2 focus-visible:ring-offset-2",
customPrefix && "!border-l-0 !rounded-l-none",
customSuffix && "!pr-16",
suffix && "!pr-16",
icon && "!pl-10",
"border",
className,
@@ -116,7 +137,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
props.disabled && "opacity-30",
)}
>
{customSuffix}
{suffix}
</div>
{error && errorTooltip && (
<div

View File

@@ -79,6 +79,7 @@ export default function SidebarItem({
: "text-gray-600 hover:bg-gray-200 dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/50",
)}
onClick={handleClick}
data-cy={"left-navigation-item"}
>
{isChild && isNavigationCollapsed && !mobileNavOpen && (
<div

View File

@@ -23,6 +23,8 @@ export interface SelectOption {
width?: number;
country?: string;
}>;
renderItem?: () => React.ReactNode;
searchValue?: string;
}
interface SelectDropdownProps {
@@ -41,6 +43,7 @@ interface SelectDropdownProps {
size?: "xs" | "sm";
children?: React.ReactNode;
maxHeight?: number;
triggerClassName?: string;
}
export function SelectDropdown({
@@ -59,6 +62,7 @@ export function SelectDropdown({
size = "sm",
children,
maxHeight,
triggerClassName,
}: Readonly<SelectDropdownProps>) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -82,7 +86,7 @@ export function SelectDropdown({
const filteredItems = React.useMemo(() => {
if (isEmpty(debouncedSearch)) return options;
return options.filter((item) => {
const value = `${item.label}${item.value}` || "";
const value = item?.searchValue || `${item.label}${item.value}` || "";
return value.toLowerCase().includes(debouncedSearch.toLowerCase());
});
}, [options, debouncedSearch]);
@@ -139,7 +143,11 @@ export function SelectDropdown({
setOpen(isOpen);
}}
>
<PopoverTrigger asChild={!children} disabled={disabled || isLoading}>
<PopoverTrigger
asChild={!children}
disabled={disabled || isLoading}
className={triggerClassName}
>
{children ? (
children
) : (
@@ -147,7 +155,7 @@ export function SelectDropdown({
variant={variant}
disabled={disabled || isLoading}
ref={inputRef}
className={cn("w-full", className)}
className={cn("w-full focus:outline-none", className)}
>
<div className={"w-full flex justify-between items-center gap-2"}>
{isLoading && <Loading />}
@@ -248,7 +256,7 @@ const SelectDropdownItem = ({
<div ref={elementRef} className={"transition-all"}>
{visible ? (
<CommandItem
value={value}
value={option?.searchValue ?? value}
ref={elementRef}
className={"py-1 px-2"}
onSelect={() => toggle(option.value)}
@@ -256,14 +264,17 @@ const SelectDropdownItem = ({
>
<div className={"flex items-center gap-2.5 p-1"}>
{option.icon && <option.icon size={14} width={14} />}
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
)}
>
<span className={"text-nb-gray-200"}>{option.label}</span>
</div>
{option?.renderItem && option.renderItem()}
{!option?.renderItem && (
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
)}
>
<span className={"text-nb-gray-200"}>{option.label}</span>
</div>
)}
</div>
{showValue && (
<div className={"flex items-center gap-2.5 p-1"}>

View File

@@ -600,7 +600,9 @@ export function DataTable<TData, TValue>({
</AccordionItem>
);
return renderRow ? renderRow(row.original, rowContent) : rowContent;
return renderRow
? renderRow(row.original, rowContent)
: rowContent;
})
) : (
<TableRowUnstyledComponent>

View File

@@ -12,21 +12,22 @@ const variants = cva(
variant: {
default:
"bg-nb-gray-900/50 border-nb-gray-800/30 border-b text-nb-gray-200",
important: "from-netbird to-netbird-400 bg-gradient-to-b text-white",
important:
"from-netbird to-netbird-400 bg-gradient-to-b text-black font-normal",
},
tagBadge: {
default: "bg-nb-gray-200/10 text-nb-gray-100 font-medium",
important: "bg-white text-netbird font-medium",
important: "bg-nb-gray-900 text-nb-gray-200 font-medium",
},
closeButton: {
default:
"bg-nb-gray-900 rounded-md p-1 text-nb-gray-300 hover:bg-nb-gray-800",
important:
"bg-netbird-100 rounded-md p-1 text-netbird-600 hover:bg-white",
"bg-netbird rounded-md p-1 text-nb-gray-900 hover:bg-nb-gray-900 hover:text-nb-gray-200",
},
inlineLink: {
default: "text-nb-blue-400 hover:underline",
important: "!text-white underline hover:opacity-80",
important: "!text-black underline hover:opacity-80",
},
},
},

View File

@@ -7,10 +7,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
const initialAnnouncements: Announcement[] = [
{
tag: "New",
text: "NetBird v0.60.0 - Identity-aware, private SSH over your NetBird network.",
link: "https://docs.netbird.io/how-to/ssh",
linkText: "Documentation",
variant: "default", // "default" or "important"
text: "NetBird v0.61 Released - Granular SSH Access Control and Automatic Updates",
link: "https://netbird.io/knowledge-hub/granular-ssh-access-automatic-updates",
linkText: "Read Release Article",
variant: "important", // "default" or "important"
isExternal: true,
closeable: true,
isCloudOnly: false,

View File

@@ -0,0 +1,93 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import React, { createContext, useContext, useEffect, useState } from "react";
import FullScreenLoading from "@/components/ui/FullScreenLoading";
import { fetchInstanceStatus } from "@/utils/unauthenticatedApi";
import { isNetBirdHosted } from "@utils/netbird";
interface InstanceSetupContextType {
setupRequired: boolean;
loading: boolean;
}
const InstanceSetupContext = createContext<InstanceSetupContextType>({
setupRequired: false,
loading: true,
});
export const useInstanceSetup = () => useContext(InstanceSetupContext);
// Check if we're in an OIDC callback flow (hash-based routing)
const isOIDCCallback = () => {
if (typeof window === "undefined") return false;
const hash = window.location.hash;
return hash.startsWith("#callback") || hash.startsWith("#silent-callback");
};
export default function InstanceSetupProvider({
children,
}: {
children: React.ReactNode;
}) {
const [setupRequired, setSetupRequired] = useState(false);
const [loading, setLoading] = useState(true);
const router = useRouter();
const pathname = usePathname();
// Routes that don't need setup check
const bypassRoutes = ["/setup", "/install"];
const shouldBypass =
bypassRoutes.includes(pathname) || isOIDCCallback();
// Skip setup check for NetBird hosted (cloud) deployments
const isCloud = isNetBirdHosted();
// Check instance status on mount
useEffect(() => {
// Skip check for cloud deployments or bypass routes
if (isCloud || shouldBypass) {
setLoading(false);
return;
}
// Check if instance setup is required
fetchInstanceStatus()
.then((status) => {
if (status.setup_required) {
setSetupRequired(true);
}
})
.catch((err) => {
// If API fails (e.g., endpoint doesn't exist on older versions),
// assume setup is not required and continue normally
console.warn("Instance status check failed:", err);
})
.finally(() => {
setLoading(false);
});
}, [shouldBypass, isCloud]);
// Handle redirect separately to avoid setState during render conflicts
useEffect(() => {
if (setupRequired && !shouldBypass) {
router.replace("/setup");
}
}, [setupRequired, shouldBypass, router]);
// Show loading while checking (only for non-cloud, non-bypass routes)
if (loading && !shouldBypass && !isCloud) {
return <FullScreenLoading />;
}
// If setup required and not on setup page, wait for redirect
if (setupRequired && !shouldBypass) {
return <FullScreenLoading />;
}
return (
<InstanceSetupContext.Provider value={{ setupRequired, loading }}>
{children}
</InstanceSetupContext.Provider>
);
}

View File

@@ -4,6 +4,9 @@ import { useEffect, useRef } from "react";
const config = loadConfig();
const RETRY_DELAY = 1250;
const MAX_RETRIES = 10;
export const useRedirect = (
url: string,
replace: boolean = false,
@@ -12,40 +15,51 @@ export const useRedirect = (
const router = useRouter();
const currentPath = usePathname();
const callBackUrls = useRef([config.redirectURI, config.silentRedirectURI]);
const isRedirecting = useRef(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const retryCountRef = useRef(0);
useEffect(() => {
// Parse URL to separate path and query params
const [targetPath] = url.split("?");
const currentFullPath = window.location.pathname;
// If redirect is disabled or the url is already in the callback urls then do not redirect
if (!enable || callBackUrls.current.includes(url) || url === currentPath)
if (!enable || callBackUrls.current.includes(url)) {
return;
}
// Check if we're already on the target path
if (targetPath === currentFullPath || targetPath === currentPath) {
return;
}
const performRedirect = () => {
if (!isRedirecting.current) {
isRedirecting.current = true;
router.refresh();
if (replace) {
router.replace(url);
} else {
router.push(url);
}
isRedirecting.current = false;
if (replace) {
router.replace(url);
} else {
router.push(url);
}
retryCountRef.current += 1;
// Retry if navigation hasn't occurred and we haven't exceeded max retries
if (retryCountRef.current < MAX_RETRIES) {
timeoutRef.current = setTimeout(() => {
// Check again if we're still not on the target path
if (window.location.pathname !== targetPath) {
performRedirect();
}
}, RETRY_DELAY);
}
};
performRedirect();
// Try to redirect after 1.25 seconds if for whatever reason the redirect did not happen (network change, browser tab open but not focused etc.)
intervalRef.current = setInterval(() => {
if (!isRedirecting.current) {
performRedirect();
}
}, 1250);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
retryCountRef.current = 0;
};
}, [replace, router, url, enable, currentPath]);
};

View File

@@ -22,6 +22,7 @@ export interface Account {
dns_domain: string;
network_range?: string;
lazy_connection_enabled: boolean;
embedded_idp_enabled?: boolean;
auto_update_version: string;
};
onboarding?: AccountOnboarding;

View File

@@ -30,3 +30,55 @@ export interface IdentityProviderLog {
level: string;
timestamp: Date;
}
export type SSOIdentityProviderType =
| "oidc"
| "zitadel"
| "entra"
| "google"
| "okta"
| "pocketid"
| "microsoft"
| "authentik"
| "keycloak";
export const SSOIdentityProviderOptions: {
value: SSOIdentityProviderType;
label: string;
}[] = [
{ value: "oidc", label: "OIDC (Generic)" },
{ value: "google", label: "Google" },
{ value: "microsoft", label: "Microsoft" },
{ value: "entra", label: "Microsoft Entra" },
{ value: "okta", label: "Okta" },
{ value: "zitadel", label: "Zitadel" },
{ value: "pocketid", label: "PocketID" },
{ value: "authentik", label: "Authentik" },
{ value: "keycloak", label: "Keycloak" },
];
export const getSSOIdentityProviderLabelByType = (
type: SSOIdentityProviderType,
) => {
return (
SSOIdentityProviderOptions.find((option) => option.value === type)?.label ??
type
);
};
export interface SSOIdentityProvider {
id: string;
type: SSOIdentityProviderType;
name: string;
issuer: string;
client_id: string;
redirect_url?: string;
}
export interface SSOIdentityProviderRequest {
type: SSOIdentityProviderType;
name: string;
issuer: string;
client_id: string;
client_secret: string;
}

View File

@@ -0,0 +1,19 @@
export interface InstanceStatus {
setup_required: boolean;
}
export interface SetupRequest {
email: string;
password: string;
name: string;
}
export interface SetupResponse {
user_id: string;
email: string;
}
export interface ApiError {
code: number;
message: string;
}

View File

@@ -22,6 +22,7 @@ export interface Permissions {
settings: Permission;
accounts: Permission;
billing: Permission;
identity_providers: Permission;
edr: Permission;
event_streaming: Permission;

View File

@@ -13,6 +13,8 @@ export interface User {
pending_approval?: boolean;
last_login?: Date;
permissions: Permissions;
password?: string;
idp_id?: string;
}
export enum Role {

View File

@@ -18,6 +18,7 @@ import AnalyticsProvider, {
import DialogProvider from "@/contexts/DialogProvider";
import ErrorBoundaryProvider from "@/contexts/ErrorBoundary";
import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider";
import InstanceSetupProvider from "@/contexts/InstanceSetupProvider";
import { NavigationEvents } from "@/contexts/NavigationEvents";
const inter = localFont({
@@ -47,11 +48,13 @@ export default function AppLayout({
<DialogProvider>
<GlobalThemeProvider>
<ErrorBoundaryProvider>
<OIDCProvider>
<TooltipProvider delayDuration={0}>
{children}
</TooltipProvider>
</OIDCProvider>
<InstanceSetupProvider>
<OIDCProvider>
<TooltipProvider delayDuration={0}>
{children}
</TooltipProvider>
</OIDCProvider>
</InstanceSetupProvider>
</ErrorBoundaryProvider>
</GlobalThemeProvider>
</DialogProvider>

View File

@@ -258,7 +258,7 @@ export default function AccessControlTable({
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
inset={!isGroupPage}
inset={false}
minimal={isGroupPage}
keepStateInLocalStorage={!isGroupPage || !idParam}
initialSearch={idParam ? "" : undefined}

View File

@@ -707,6 +707,31 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Identity Provider
*/
if (event.activity_code == "identityprovider.create")
return (
<div className={"inline"}>
Identity provider <Value>{m.name}</Value> was created
</div>
);
if (event.activity_code == "identityprovider.update")
return (
<div className={"inline"}>
Identity provider <Value>{m.name}</Value> was updated
</div>
);
if (event.activity_code == "identityprovider.delete")
return (
<div className={"inline"}>
Identity provider <Value>{m.name}</Value> was deleted
</div>
);
return (
<div className={"flex gap-2.5 items-center"}>
<span className={"mb-[1px]"}>{event.activity}</span>

View File

@@ -4,6 +4,7 @@ import {
Blocks,
Cog,
CreditCardIcon,
FingerprintIcon,
FolderGit2,
Globe,
HelpCircleIcon,
@@ -52,6 +53,7 @@ const ActivityTypeMappings = {
transferred: RefreshCcw,
resource: Layers3Icon,
network: NetworkIcon,
identityprovider: FingerprintIcon,
} as const satisfies Record<string, LucideIcon>;
export default function ActivityTypeIcon({

View File

@@ -1,11 +1,17 @@
import { SegmentedTabs } from "@components/SegmentedTabs";
import { FolderGit2, MonitorSmartphoneIcon, NetworkIcon } from "lucide-react";
import {
FolderGit2,
MonitorSmartphoneIcon,
NetworkIcon,
UsersIcon,
} from "lucide-react";
import * as React from "react";
export enum FlowView {
NETWORKS = "networks",
GROUPS = "groups",
PEERS = "peers",
USERS = "users",
}
type Props = {
@@ -26,14 +32,21 @@ export const FlowSelector = ({ value, onChange }: Props) => {
className={"text-xs px-3 py-1"}
>
<MonitorSmartphoneIcon size={12} />
Peers
Peer
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger
value={FlowView.USERS}
className={"text-xs px-3 py-1"}
>
<UsersIcon size={12} />
User
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger
value={FlowView.GROUPS}
className={"text-xs px-3 py-1"}
>
<FolderGit2 size={12} />
Groups
Group
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger
value={FlowView.NETWORKS}

View File

@@ -1,48 +1,38 @@
import Button from "@components/Button";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { useRouter } from "next/navigation";
import * as React from "react";
import { useMemo } from "react";
import CircleIcon from "@/assets/icons/CircleIcon";
import { Network, NetworkRouter } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import { Network } from "@/interfaces/Network";
type Props = {
network: Network;
};
export const NetworkRoutingPeerCount = ({ network }: Props) => {
const { data: routers, isLoading: isRoutersLoading } =
useFetchApi<NetworkRouter[]>("/networks/routers");
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const router = useRouter();
const routerCount = network?.routing_peers_count ?? 0;
const routingPeerStatusColor = useMemo(() => {
if (!network) return "bg-nb-gray-500";
const routerCount = network.routers?.length || 0;
if (routerCount === 0) return "bg-nb-gray-500";
if (routerCount === 1) return "bg-yellow-400";
if (routerCount > 1) return "bg-green-400";
return "bg-nb-gray-500";
}, [network]);
}, [network, routerCount]);
const networkRouters = useMemo(() => {
if (!network || !peers) return [];
const routerIds = network?.routers?.map((r) => r) || [];
return routers?.filter((r) => routerIds.includes(r.id)) || [];
}, [network, peers, routers]);
const openNetworkPage = () => {
router.push(`/network?id=${network.id}#routing-peers`);
};
return (
<Button
variant={"secondary"}
size={"xs"}
className={"!bg-nb-gray-930 !text-nb-gray-300 cursor-default"}
>
<Button variant={"secondary"} size={"xs"} onClick={openNetworkPage}>
<CircleIcon
size={8}
className={cn("shrink-0 block", routingPeerStatusColor)}
/>
{network.routers?.length || 0} Routing Peer(s)
{routerCount} Routing Peer(s)
</Button>
);
};

View File

@@ -4,11 +4,12 @@ import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import { useMemo } from "react";
import { Group } from "@/interfaces/Group";
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type GroupNodeProps = Node<
{
group: Group;
enabled: boolean;
enabled?: boolean;
hoverable?: boolean;
onClick?: (g: Group) => void;
},
@@ -16,7 +17,9 @@ type GroupNodeProps = Node<
>;
export const GroupNode = ({ data, id }: GroupNodeProps) => {
const { enabled = true, group, hoverable = true, onClick } = data;
const { enabled, group, hoverable = true, onClick } = data;
const sourceGroupEnabled = useAnySourceGroupEnabled(id);
const isEnabled = enabled ?? sourceGroupEnabled;
const countLabel = useMemo(() => {
const peerCount = group?.peers_count || 0;
@@ -34,7 +37,7 @@ export const GroupNode = ({ data, id }: GroupNodeProps) => {
<div
className={cn(
"cc-group-node bg-nb-gray-940 border border-nb-gray-800 rounded-lg overflow-hidden transition-all",
!enabled && "opacity-60",
!isEnabled && "opacity-60",
hoverable && "hover:bg-nb-gray-930 cursor-pointer",
)}
onClick={() => onClick?.(group)}

View File

@@ -4,8 +4,8 @@ import { Handle, type Node, Position } from "@xyflow/react";
import { NetworkIcon } from "lucide-react";
import * as React from "react";
import CircleIcon from "@/assets/icons/CircleIcon";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { Network, NetworkResource } from "@/interfaces/Network";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
type NetworkNodeType = {
network: Network;
@@ -14,13 +14,13 @@ type NetworkNodeType = {
type NetworkNodeProps = Node<NetworkNodeType, "networkNode">;
export const NetworkNode = ({ data }: NetworkNodeProps) => {
const { data: networkResources, isLoading: isLoadingResources } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const { data: networkResources } = useFetchApi<NetworkResource[]>(
"/networks/resources",
);
const n = data.network as Network;
const routingPeersCount = n?.routing_peers_count ?? 0;
const resourceIds = n?.resources || [];
const routingPeers = n?.routers || [];
const resources =
networkResources?.filter((r) => resourceIds.includes(r?.id || "")) || [];
@@ -56,12 +56,12 @@ export const NetworkNode = ({ data }: NetworkNodeProps) => {
size={8}
className={cn(
"shrink-0 block",
routingPeers?.length === 0 && "bg-nb-gray-500",
routingPeers?.length === 1 && "bg-yellow-400",
routingPeers?.length > 1 && "bg-green-400",
routingPeersCount === 0 && "bg-nb-gray-500",
routingPeersCount === 1 && "bg-yellow-400",
routingPeersCount > 1 && "bg-green-400",
)}
/>
{routingPeers?.length || 0} Routing Peer(s)
{routingPeersCount} Routing Peer(s)
</div>
</div>

View File

@@ -9,23 +9,28 @@ type PeerNodeProps = Node<
{
peer: Peer;
enabled?: boolean;
onClick?: (p: Peer) => void;
},
"peerNode"
>;
export const PeerNode = ({ data, id }: PeerNodeProps) => {
const { peer, enabled } = data;
const isEnabled = useAnySourceGroupEnabled(id);
const { peer, enabled, onClick } = data;
const sourceGroupEnabled = useAnySourceGroupEnabled(id);
const isEnabled = enabled ?? sourceGroupEnabled;
return (
<div
className={
"border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
className={cn(
"border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all",
onClick &&
"border-transparent border hover:border-nb-gray-800 rounded-lg hover:bg-nb-gray-930 cursor-pointer pl-3 py-1 pr-5",
)}
onClick={() => onClick?.(peer)}
>
<DeviceCard
device={peer}
className={cn("p-0", !isEnabled && "opacity-60")}
className={cn("p-0", !isEnabled && "opacity-60", onClick && "w-auto")}
/>
<Handle
type="source"

View File

@@ -2,30 +2,35 @@ import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import { NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type ResourceNode = Node<
{
resource: NetworkResource;
resource?: NetworkResource;
peer?: Peer;
enabled?: boolean;
className?: string;
},
"resourceNode"
>;
export const ResourceNode = ({ data, id }: ResourceNode) => {
const { enabled, resource } = data;
const isEnabled = useAnySourceGroupEnabled(id);
const { enabled, resource, peer, className } = data;
const sourceGroupEnabled = useAnySourceGroupEnabled(id);
const isEnabled = enabled ?? sourceGroupEnabled;
return (
<div
className={
"cursor-pointer border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
className={cn(
"cursor-pointer border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all",
className,
)}
>
<DeviceCard
resource={resource}
device={peer}
className={cn("p-0", !isEnabled && "opacity-60")}
/>
<Handle

View File

@@ -17,6 +17,8 @@ type PeerNodeProps = Node<
{
currentPeer: string;
onPeerChange: (peerId: string) => void;
userId?: string;
placeholder?: string;
},
"selectPeerNode"
>;
@@ -25,10 +27,13 @@ export const SelectPeerNode = ({ data, id }: PeerNodeProps) => {
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const userId = data?.userId;
const peerSelectOptions: SelectOption[] = sortBy(
peers?.map(
(p) =>
({
peers
?.filter((p) => !userId || p.user_id === userId)
.map((p) => {
return {
value: p.id,
label: p.name,
icon: () => {
@@ -47,9 +52,9 @@ export const SelectPeerNode = ({ data, id }: PeerNodeProps) => {
</div>
);
},
}) as SelectOption,
) || [],
"label",
} as SelectOption;
}) || [],
["label", "value"],
"asc",
);
@@ -67,7 +72,7 @@ export const SelectPeerNode = ({ data, id }: PeerNodeProps) => {
onChange={data.onPeerChange}
options={peerSelectOptions}
showSearch={true}
searchPlaceholder={"Search peers..."}
searchPlaceholder={data?.placeholder ?? "Search peers..."}
popoverWidth={280}
className={"!bg-nb-gray-920 !hover:bg-nb-gray-925 !text-nb-gray-300"}
size={"xs"}

View File

@@ -0,0 +1,172 @@
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import useFetchApi from "@utils/api";
import { cn, generateColorFromUser } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import { sortBy } from "lodash";
import { ChevronsUpDown, Cog } from "lucide-react";
import * as React from "react";
import { User } from "@/interfaces/User";
import TruncatedText from "@components/ui/TruncatedText";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
type UserNodeProps = Node<
{
currentUser: string;
onUserChange: (peerId: string) => void;
},
"selectUserNode"
>;
export const SelectUserNode = ({ data, id }: UserNodeProps) => {
const { data: users } = useFetchApi<User[]>("/users?service_user=false");
const userSelectOptions: SelectOption[] = sortBy(
users?.map(
(user) =>
({
value: user.id,
label: user.name,
searchValue: `${user.id}${user.email}${user.name}`,
renderItem: () => {
return (
<div className={"flex items-center gap-2 w-full"}>
<SmallUserAvatar
name={user?.name}
email={user?.email}
id={user?.id}
/>
<div className={"flex flex-col text-xs w-full"}>
<span
className={
"text-nb-gray-200 flex items-center gap-1.5 w-full"
}
>
<TextWithTooltip
text={user?.name || user?.id}
maxChars={20}
/>
</span>
{user?.email && (
<span
className={
"text-nb-gray-400 font-light flex items-center gap-1"
}
>
<TextWithTooltip
text={user?.email || "NetBird"}
maxChars={20}
/>
</span>
)}
</div>
</div>
);
},
}) as SelectOption,
) || [],
"label",
"asc",
);
const user = users?.find((u) => u.id === data.currentUser);
return (
<div
className={cn(
"bg-nb-gray-930 border hover:bg-nb-gray-910 cursor-pointer border-nb-gray-800 rounded-lg overflow-hidden transition-all",
)}
>
<SelectDropdown
variant={"secondary"}
value={data.currentUser}
onChange={data.onUserChange}
options={userSelectOptions}
showSearch={true}
searchPlaceholder={"Search user by name or email..."}
popoverWidth={280}
className={cn(
"!bg-nb-gray-920 !hover:bg-nb-gray-925 !text-nb-gray-300",
)}
triggerClassName={"focus:outline-none focus-visible:outline-none"}
size={"xs"}
maxHeight={300}
>
<div
className={"flex items-center justify-between gap-8 pr-3 py-2 pl-3"}
>
{user && <SelectedUser user={user} />}
<ChevronsUpDown size={18} className={"shrink-0"} />
</div>
</SelectDropdown>
<Handle
type="source"
position={Position.Right}
id={"sr"}
style={{
height: 20,
width: "1px",
border: "none",
backgroundColor: "#3f444b",
borderRadius: "0px 4px 4px 0px",
right: -2,
}}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};
export const SelectedUser = ({
user,
className,
}: {
user: User;
className?: string;
}) => {
return (
<div className={cn("flex items-center justify-center gap-2.5", className)}>
<div
className={
"w-8 h-8 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
}
style={{
color: generateColorFromUser(user),
}}
>
{!user?.name && !user?.id && <Cog size={12} />}
{user?.name?.charAt(0) || user?.id?.charAt(0)}
</div>
<div
className={cn(
"flex flex-col justify-center relative",
user?.email && "top-[2px]",
)}
>
<span
className={
"font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
}
>
{user.name || user.id}
</span>
<TruncatedText
text={user?.email}
maxWidth={"180px"}
className={
"text-sm font-normal text-nb-gray-400 relative -top-[0.2rem]"
}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,70 @@
import Button from "@components/Button";
import TruncatedText from "@components/ui/TruncatedText";
import useFetchApi from "@utils/api";
import { cn, generateColorFromUser } from "@utils/helpers";
import { Cog } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { User } from "@/interfaces/User";
type Props = {
userId: string;
};
export const ControlCenterCurrentUserBadge = ({ userId }: Props) => {
const { data: users, isLoading: isUsersLoading } =
useFetchApi<User[]>("/users");
const user = useMemo(() => {
if (!users) return undefined;
return users?.find((u) => u.id === userId);
}, [users, userId]);
return (
user && (
<Button
variant={"secondary"}
size={"xs"}
className={
"!bg-nb-gray-930 !text-nb-gray-300 cursor-default h-[40px] !pl-2.5"
}
>
<div className={cn("flex items-center justify-center gap-2.5")}>
<div
className={
"w-6 h-6 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
}
style={{
color: generateColorFromUser(user),
}}
>
{!user?.name && !user?.id && <Cog size={12} />}
{user?.name?.charAt(0) || user?.id?.charAt(0)}
</div>
<div
className={cn(
"flex flex-col justify-center relative",
user?.email && "top-[2px]",
)}
>
<span
className={
"font-normal text-[0.7rem] text-nb-gray-100 flex items-center gap-2"
}
>
{user.name || user.id}
</span>
<TruncatedText
text={user?.email}
maxWidth={"380px"}
className={
"text-[0.7rem] font-normal text-nb-gray-400 relative -top-[0.2rem]"
}
/>
</div>
</div>
</Button>
)
);
};

View File

@@ -114,7 +114,7 @@ export function useAnySourceGroupEnabled(sourceId: string) {
const sourceNodes = incomingEdges
.map((edge) => nodes.find((n) => n.id === edge.source))
.filter(Boolean);
const sourceEnabledStates = sourceNodes.map((n) => n?.data?.enabled);
const sourceEnabledStates = incomingEdges.map((e) => e?.data?.enabled);
return sourceEnabledStates.some(Boolean);
}

View File

@@ -8,7 +8,7 @@ interface SimulationNode extends Node {
vy?: number;
}
export const DEFAULT_MAX_ZOOM = 0.8;
export const DEFAULT_MAX_ZOOM = 1.6;
export const DEFAULT_MIN_ZOOM = 0.2;
export const applyD3ForceLayout = (nodes: Node[], edges: Edge[]) => {
@@ -97,6 +97,9 @@ export const applyD3HierarchicalLayout = (
const startX = 0;
const centerY = 0;
const sourcePeerNodes = simulationNodes.filter(
(n) => n.type === "sourcePeerNode",
);
const groupNodes = simulationNodes.filter((n) => n.type === "groupNode");
const sourceGroupNodes = simulationNodes.filter(
(n) => n.type === "sourceGroupNode",
@@ -104,6 +107,9 @@ export const applyD3HierarchicalLayout = (
const destinationGroupNodes = simulationNodes.filter(
(n) => n.type === "destinationGroupNode",
);
const destinationResourceNodes = simulationNodes.filter(
(n) => n.type === "destinationResourceNode",
);
const policyNodes = simulationNodes.filter((n) => n.type === "policyNode");
const networkNodes = simulationNodes.filter((n) => n.type === "networkNode");
const resourceNodes = simulationNodes.filter(
@@ -127,6 +133,14 @@ export const applyD3HierarchicalLayout = (
];
}
// Source Peer
centerNodesVertically(
sourcePeerNodes,
startX - 100,
nodeSpacing / 1.5,
centerY,
);
// Peers
if (peerNodes.length > 0 && view !== "group") {
centerNodesVertically(
@@ -156,7 +170,7 @@ export const applyD3HierarchicalLayout = (
// Destination Groups
centerNodesVertically(
destinationGroupNodes,
[...destinationGroupNodes, ...destinationResourceNodes],
startX + (options?.destinationGroup?.width ?? columnWidth),
options?.destinationGroup?.spacing ?? nodeSpacing,
centerY,

View File

@@ -5,16 +5,23 @@ import { PolicyNode } from "@/modules/control-center/nodes/PolicyNode";
import { ResourceNode } from "@/modules/control-center/nodes/ResourceNode";
import { SelectGroupNode } from "@/modules/control-center/nodes/SelectGroupNode";
import { SelectPeerNode } from "@/modules/control-center/nodes/SelectPeerNode";
import { SelectUserNode } from "@/modules/control-center/nodes/SelectUserNode";
export const NODE_TYPES = {
groupNode: GroupNode,
sourceGroupNode: GroupNode,
destinationGroupNode: GroupNode,
destinationResourceNode: ResourceNode,
networkNode: NetworkNode,
resourceNode: ResourceNode,
policyNode: PolicyNode,
peerNode: PeerNode,
sourcePeerNode: PeerNode,
expandedGroupPeer: PeerNode,
selectPeerNode: SelectPeerNode,
selectGroupNode: SelectGroupNode,
selectUserNode: SelectUserNode,
};

View File

@@ -1,4 +1,3 @@
import { usePortalElement } from "@hooks/usePortalElement";
import React, { lazy } from "react";
import { useGroupContext } from "@/contexts/GroupProvider";
import { NameserverGroup } from "@/interfaces/Nameserver";

View File

@@ -0,0 +1,294 @@
"use client";
import { cn } from "@utils/helpers";
import { CheckCircle2, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { ApiError, SetupRequest } from "@/interfaces/Instance";
import { submitSetup } from "@/utils/unauthenticatedApi";
import { NetBirdLogo } from "@components/NetBirdLogo";
import Button from "@components/Button";
import { Label } from "@components/Label";
import { Input } from "@components/Input";
import HelpText from "@components/HelpText";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
interface FormData {
email: string;
password: string;
name: string;
}
interface FormErrors {
email?: string;
password?: string;
name?: string;
general?: string;
}
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/i;
export default function InstanceSetupWizard() {
const [formData, setFormData] = useState<FormData>({
email: "",
password: "",
name: "",
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [countdown, setCountdown] = useState(3);
const validateForm = useCallback((): boolean => {
const newErrors: FormErrors = {};
if (!formData.email.trim()) {
newErrors.email = "Email is required";
} else if (!emailRegex.test(formData.email)) {
newErrors.email = "Please enter a valid email address";
}
if (!formData.password) {
newErrors.password = "Password is required";
} else if (formData.password.length < 8) {
newErrors.password = "Password must be at least 8 characters";
}
if (!formData.name.trim()) {
newErrors.name = "Name is required";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [formData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setIsSubmitting(true);
setErrors({});
try {
const request: SetupRequest = {
email: formData.email.trim(),
password: formData.password,
name: formData.name.trim(),
};
await submitSetup(request);
setIsSuccess(true);
} catch (err) {
const error = err as ApiError;
let message = "An error occurred. Please try again.";
switch (error.code) {
case 400:
message = "Invalid request. Please check your input.";
break;
case 412:
message = "Setup has already been completed. Redirecting to login...";
setTimeout(() => (window.location.href = "/"), 2000);
break;
case 422:
message =
error.message || "Validation error. Please check your input.";
break;
case 500:
message = "An error occurred. Please try again.";
break;
default:
message = error.message || message;
}
setErrors({ general: message });
} finally {
setIsSubmitting(false);
}
};
useEffect(() => {
if (!isSuccess) return;
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
// Full page reload to get fresh instance status from API
window.location.href = "/";
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [isSuccess]);
const handleInputChange =
(field: keyof FormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
if (isSuccess) {
return (
<div className="mt-20">
<div className={"flex items-center justify-center"}>
<NetBirdLogo size={"large"} mobile={false} />
</div>
<Card className={"max-w-[360px] mt-8 mx-auto"}>
<div className="w-10 h-10 rounded-full bg-green-500/10 flex items-center justify-center mb-4 mx-auto">
<CheckCircle2 className="text-green-500" size={22} />
</div>
<h1 className={"text-xl text-center z-10 relative"}>
Account Created!
</h1>
<div
className={
"text-sm text-nb-gray-300 font-light mt-2 block text-center z-10 relative"
}
>
You are being redirected to login in{" "}
<span className={"text-white font-medium"}>{countdown}s</span>...
</div>
<div className={"flex items-center justify-center mt-4"}>
<Button
type="button"
onClick={() => (window.location.href = "/")}
variant={"primary"}
className={"mx-auto w-full"}
>
Go to Login
</Button>
</div>
</Card>
</div>
);
}
return (
<div className="mt-20">
<div className={"flex items-center justify-center"}>
<NetBirdLogo size={"large"} mobile={false} />
</div>
<Card className={"max-w-[420px] mt-8 mx-auto"}>
<h1 className={"text-xl text-center z-10 relative"}>
Welcome to NetBird
</h1>
<div
className={
"text-sm text-nb-gray-300 font-light mt-2 block text-center z-10 relative"
}
>
Create the first admin account to get started
</div>
<form
onSubmit={handleSubmit}
className={"flex flex-col gap-5 mt-7 z-10 relative"}
>
{errors.general && <ErrorMessage error={errors.general} />}
<div>
<Label htmlFor={"name"}>Name</Label>
<Input
type="text"
id="name"
value={formData.name}
onChange={handleInputChange("name")}
placeholder="Your name"
disabled={isSubmitting}
autoFocus
error={errors.name}
/>
</div>
<div>
<Label htmlFor={"email"}>Email</Label>
<Input
type="email"
id="email"
value={formData.email}
onChange={handleInputChange("email")}
placeholder="admin@example.com"
disabled={isSubmitting}
error={errors.email}
/>
</div>
<div>
<Label htmlFor={"password"}>Password</Label>
<Input
type={"password"}
id="password"
value={formData.password}
onChange={handleInputChange("password")}
placeholder="Enter a strong password"
disabled={isSubmitting}
error={errors.password}
showPasswordToggle={true}
/>
<HelpText className={"mt-2"}>
Must be at least 8 characters
</HelpText>
</div>
<Button
type={"submit"}
disabled={isSubmitting}
variant={"primary"}
className={"w-full"}
>
{isSubmitting ? (
<>
<Loader2 className="animate-spin" size={16} />
Creating Account...
</>
) : (
"Create Admin Account"
)}
</Button>
</form>
</Card>
<div className={"flex items-center justify-center mt-6"}>
<span
className={"text-sm text-nb-gray-400 font-light pb-10 text-center"}
>
This is a one-time setup for your NetBird instance.
</span>
</div>
</div>
);
}
const Card = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<div
className={cn(
"px-6 sm:px-10 py-8 pt-6",
"bg-nb-gray-940 border border-nb-gray-910 rounded-lg relative",
className,
)}
>
<GradientFadedBackground />
{children}
</div>
);
};
const ErrorMessage = ({ error }: { error?: string }) => {
return (
<div className="text-red-400 bg-red-800/20 border border-red-800/50 rounded-lg px-4 py-3 whitespace-break-spaces my-3 text-sm">
{error}
</div>
);
};

View File

@@ -23,6 +23,7 @@ import { PostureCheckActionCell } from "@/modules/posture-checks/table/cells/Pos
import { PostureCheckChecksCell } from "@/modules/posture-checks/table/cells/PostureCheckChecksCell";
import { PostureCheckNameCell } from "@/modules/posture-checks/table/cells/PostureCheckNameCell";
import { PostureCheckPolicyUsageCell } from "@/modules/posture-checks/table/cells/PostureCheckPolicyUsageCell";
import PoliciesProvider from "@/contexts/PoliciesProvider";
type Props = {
isLoading: boolean;
@@ -54,7 +55,7 @@ const Columns: ColumnDef<PostureCheck>[] = [
{
id: "access_control_usage",
header: ({ column }) => {
return <DataTableHeader column={column}>Used by</DataTableHeader>;
return <DataTableHeader column={column}>Policies</DataTableHeader>;
},
cell: ({ row }) => <PostureCheckPolicyUsageCell check={row.original} />,
},
@@ -121,134 +122,137 @@ export default function PostureCheckTable({
postureCheck={currentRow}
/>
)}
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"Posture Check"}
sorting={sorting}
wrapperClassName={""}
setSorting={setSorting}
columns={Columns}
showHeader={true}
columnVisibility={{
active: false,
}}
onRowClick={(row, cell) => {
setCurrentRow(row.original);
setPostureCheckModal(true);
setCurrentCellClicked(cell);
}}
data={data}
searchPlaceholder={"Search by name and description..."}
rightSide={() => (
<>
{data && data?.length > 0 && (
<Button
variant={"primary"}
className={"ml-auto"}
disabled={
!permission.policies.create || !permission.policies.update
}
onClick={() => {
setCurrentRow(undefined);
setPostureCheckModal(true);
}}
>
<IconCirclePlus size={16} />
Add Posture Check
</Button>
)}
</>
)}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={<ShieldCheck size={23} />}
color={"gray"}
size={"large"}
/>
}
title={"Create Posture Check"}
description={
"Add posture checks to further restrict access in your network. E.g., only clients with a specific NetBird client version, operating system or location are allowed to connect."
}
button={
<Button
variant={"primary"}
className={"ml-auto"}
disabled={
!permission.policies.create || !permission.policies.update
}
onClick={() => setPostureCheckModal(true)}
>
<IconCirclePlus size={16} />
Create Posture Check
</Button>
}
learnMore={
<>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/manage-posture-checks"}
target={"_blank"}
>
Posture Checks
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
}
>
{(table) => {
return (
<PoliciesProvider>
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"Posture Check"}
sorting={sorting}
wrapperClassName={""}
setSorting={setSorting}
columns={Columns}
showHeader={true}
columnVisibility={{
active: false,
}}
onRowClick={(row, cell) => {
setCurrentRow(row.original);
setPostureCheckModal(true);
setCurrentCellClicked(cell);
}}
data={data}
searchPlaceholder={"Search by name and description..."}
rightSide={() => (
<>
<ButtonGroup disabled={data?.length == 0}>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("active")?.setFilterValue(true);
}}
disabled={data?.length == 0}
variant={
table.getColumn("active")?.getFilterValue() == true
? "tertiary"
: "secondary"
{data && data?.length > 0 && (
<Button
variant={"primary"}
className={"ml-auto"}
disabled={
!permission.policies.create || !permission.policies.update
}
>
Active
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("active")?.setFilterValue("");
setCurrentRow(undefined);
setPostureCheckModal(true);
}}
disabled={data?.length == 0}
variant={
table.getColumn("active")?.getFilterValue() != true
? "tertiary"
: "secondary"
}
>
All
</ButtonGroup.Button>
</ButtonGroup>
<DataTableRowsPerPage
table={table}
disabled={data?.length == 0}
/>
<DataTableRefreshButton
isDisabled={data?.length == 0}
onClick={() => {
mutate("/posture-checks");
}}
/>
<IconCirclePlus size={16} />
Add Posture Check
</Button>
)}
</>
);
}}
</DataTable>
)}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={<ShieldCheck size={23} />}
color={"gray"}
size={"large"}
/>
}
title={"Create Posture Check"}
description={
"Add posture checks to further restrict access in your network. E.g., only clients with a specific NetBird client version, operating system or location are allowed to connect."
}
button={
<Button
variant={"primary"}
className={"ml-auto"}
disabled={
!permission.policies.create || !permission.policies.update
}
onClick={() => setPostureCheckModal(true)}
>
<IconCirclePlus size={16} />
Create Posture Check
</Button>
}
learnMore={
<>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/manage-posture-checks"
}
target={"_blank"}
>
Posture Checks
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
}
>
{(table) => {
return (
<>
<ButtonGroup disabled={data?.length == 0}>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("active")?.setFilterValue(true);
}}
disabled={data?.length == 0}
variant={
table.getColumn("active")?.getFilterValue() == true
? "tertiary"
: "secondary"
}
>
Active
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("active")?.setFilterValue("");
}}
disabled={data?.length == 0}
variant={
table.getColumn("active")?.getFilterValue() != true
? "tertiary"
: "secondary"
}
>
All
</ButtonGroup.Button>
</ButtonGroup>
<DataTableRowsPerPage
table={table}
disabled={data?.length == 0}
/>
<DataTableRefreshButton
isDisabled={data?.length == 0}
onClick={() => {
mutate("/posture-checks");
}}
/>
</>
);
}}
</DataTable>
</PoliciesProvider>
</div>
);
}

View File

@@ -26,6 +26,7 @@ import { useSWRConfig } from "swr";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Account } from "@/interfaces/Account";
import { SmallBadge } from "@components/ui/SmallBadge";
type Props = {
account: Account;
@@ -182,6 +183,12 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
<Label>
<RefreshCcw size={15} />
Automatic Updates
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[9px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</Label>
<HelpText>
Select how NetBird clients handle automatic updates by choosing

View File

@@ -28,7 +28,9 @@ import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useHasChanges } from "@/hooks/useHasChanges";
import { Account } from "@/interfaces/Account";
import { Callout } from "@components/Callout";
import { InlineButtonLink } from "@components/InlineLink";
import { useRouter } from "next/navigation";
type Props = {
account: Account;
@@ -36,7 +38,7 @@ type Props = {
export default function GroupsSettings({ account }: Props) {
const { permission } = usePermissions();
const router = useRouter();
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
@@ -82,22 +84,22 @@ export default function GroupsSettings({ account }: Props) {
const showConfirm = jwtGroupSync && jwtGroupsEntered;
const choice = showConfirm
? await confirm({
title: `JWT allow group - ${jwtAllowGroups[0]}`,
description: `Only users part of the ${jwtAllowGroups[0]} group will be able to access NetBird. Are you sure you want to save the changes?`,
confirmText: "Save",
children: (
<div
className={
"flex gap-2 items-center text-xs bg-netbird-950 px-4 justify-center py-3 rounded-md border border-netbird-500 text-netbird-200"
}
>
<AlertCircle size={14} />
To prevent losing access, ensure you are part of this group.
</div>
),
cancelText: "Cancel",
type: "default",
})
title: `JWT allow group - ${jwtAllowGroups[0]}`,
description: `Only users part of the ${jwtAllowGroups[0]} group will be able to access NetBird. Are you sure you want to save the changes?`,
confirmText: "Save",
children: (
<div
className={
"flex gap-2 items-center text-xs bg-netbird-950 px-4 justify-center py-3 rounded-md border border-netbird-500 text-netbird-200"
}
>
<AlertCircle size={14} />
To prevent losing access, ensure you are part of this group.
</div>
),
cancelText: "Cancel",
type: "default",
})
: true;
if (!choice) return;
@@ -189,6 +191,16 @@ export default function GroupsSettings({ account }: Props) {
disabled={!permission.settings.update}
/>
)}
<Callout variant={"info"} className={""}>
Looking to view and manage your groups? You can find group
management under{" "}
<InlineButtonLink
onClick={() => router.push("/groups")}
variant={"dashed"}
>
{`Access Control Groups`}
</InlineButtonLink>
</Callout>
</div>
{(!isNetBirdHosted() || isLocalDev()) && (

View File

@@ -0,0 +1,309 @@
import Button from "@components/Button";
import Code from "@components/Code";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import Separator from "@components/Separator";
import { useApiCall } from "@utils/api";
import loadConfig from "@utils/config";
import { trim } from "lodash";
import {
FingerprintIcon,
GlobeIcon,
IdCard,
KeyIcon,
PlusCircle,
SaveIcon,
TagIcon,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { usePermissions } from "@/contexts/PermissionsProvider";
import {
SSOIdentityProvider,
SSOIdentityProviderOptions,
SSOIdentityProviderRequest,
SSOIdentityProviderType,
} from "@/interfaces/IdentityProvider";
import { idpIcon } from "@/assets/icons/IdentityProviderIcons";
const issuerHints: Partial<Record<SSOIdentityProviderType, string>> = {
keycloak: "https://keycloak.example.com/realms/{REALM}",
authentik: "https://authentik.example.com/application/o/{APP_SLUG}/",
zitadel: "https://{INSTANCE}.zitadel.cloud",
okta: "https://{ORG}.okta.com",
entra: "https://login.microsoftonline.com/{TENANT_ID}/v2.0",
pocketid: "https://pocketid.example.com",
};
const defaultNames: Record<SSOIdentityProviderType, string> = {
oidc: "Generic OIDC",
google: "Google",
microsoft: "Microsoft",
entra: "Microsoft Entra",
okta: "Okta",
zitadel: "Zitadel",
pocketid: "PocketID",
authentik: "Authentik",
keycloak: "Keycloak",
};
type Props = {
open: boolean;
onClose: () => void;
provider?: SSOIdentityProvider | null;
};
const copyMessage = "Redirect URL was copied to your clipboard!";
const config = loadConfig();
const redirectUrl = `${config.apiOrigin}/oauth2/callback`;
export default function IdentityProviderModal({
open,
onClose,
provider,
}: Readonly<Props>) {
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const isEditing = !!provider;
const createRequest = useApiCall<SSOIdentityProvider>("/identity-providers");
const updateRequest = useApiCall<SSOIdentityProvider>(
"/identity-providers/" + provider?.id,
);
const [type, setType] = useState<SSOIdentityProviderType>(
provider?.type ?? "oidc",
);
const [name, setName] = useState(provider?.name ?? "");
const [issuer, setIssuer] = useState(provider?.issuer ?? "");
const [clientId, setClientId] = useState(provider?.client_id ?? "");
const [clientSecret, setClientSecret] = useState("");
const requiresIssuer = type !== "google" && type !== "microsoft";
const clientIdChanged = isEditing && trim(clientId) !== provider?.client_id;
const isDisabled = useMemo(() => {
const trimmedName = trim(name);
const trimmedIssuer = trim(issuer);
const trimmedClientId = trim(clientId);
const trimmedClientSecret = trim(clientSecret);
if (trimmedName.length === 0) return true;
if (requiresIssuer && trimmedIssuer.length === 0) return true;
if (trimmedClientId.length === 0) return true;
// Client secret required for new providers, or when client ID changed during edit
if ((!isEditing || clientIdChanged) && trimmedClientSecret.length === 0)
return true;
return false;
}, [name, issuer, clientId, clientSecret, isEditing, clientIdChanged, requiresIssuer]);
const submit = () => {
const payload: SSOIdentityProviderRequest = {
type,
name: trim(name),
issuer: trim(issuer),
client_id: trim(clientId),
client_secret: trim(clientSecret),
};
if (isEditing) {
notify({
title: "Update Identity Provider",
description: "Identity provider was updated successfully.",
promise: updateRequest.put(payload).then(() => {
mutate("/identity-providers");
onClose();
}),
loadingMessage: "Updating identity provider...",
});
} else {
notify({
title: "Create Identity Provider",
description: "Identity provider was created successfully.",
promise: createRequest.post(payload).then(() => {
mutate("/identity-providers");
onClose();
}),
loadingMessage: "Creating identity provider...",
});
}
};
return (
<>
<Modal
open={open}
onOpenChange={(state) => !state && onClose()}
key={open ? 1 : 0}
>
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
icon={<FingerprintIcon size={20} />}
title={
isEditing ? "Edit Identity Provider" : "Add Identity Provider"
}
description={
isEditing
? "Update the identity provider configuration"
: "Configure a new identity provider for authentication"
}
color={"netbird"}
/>
<Separator />
<div className={"px-8 py-6 flex flex-col gap-6"}>
<div>
<Label>Provider Type</Label>
<HelpText>Select the type of identity provider</HelpText>
<Select
value={type}
onValueChange={(v) => {
const newType = v as SSOIdentityProviderType;
setType(newType);
if (!isEditing) {
setName(defaultNames[newType]);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider type..." />
</SelectTrigger>
<SelectContent>
{SSOIdentityProviderOptions.map((idp) => (
<SelectItem key={idp.value} value={idp.value}>
<div className="flex items-center gap-2">
{idpIcon(idp.value)}
<span>{idp.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Name</Label>
<HelpText>A friendly name to identify this provider</HelpText>
<Input
placeholder={"e.g., Corporate SSO"}
value={name}
onChange={(e) => setName(e.target.value)}
customPrefix={
<TagIcon size={16} className="text-nb-gray-300" />
}
/>
</div>
{requiresIssuer && (
<div>
<Label>Issuer URL</Label>
<HelpText>The OIDC issuer URL for this provider</HelpText>
<Input
placeholder={issuerHints[type] ?? "https://login.example.com"}
value={issuer}
onChange={(e) => setIssuer(e.target.value)}
customPrefix={
<GlobeIcon size={16} className="text-nb-gray-300" />
}
/>
</div>
)}
<div>
<Label>Client ID</Label>
<HelpText>The OAuth2 confidential client ID</HelpText>
<Input
placeholder={"Enter client ID"}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
customPrefix={<IdCard size={16} className="text-nb-gray-300" />}
/>
</div>
<div>
<Label>Client Secret</Label>
<HelpText>
{isEditing
? clientIdChanged
? "Required when client ID is changed"
: "Leave empty to keep the existing secret, or enter a new one"
: "The OAuth2 client secret"}
</HelpText>
<Input
type="password"
placeholder={isEditing ? "••••••••" : "Enter client secret"}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
customPrefix={
<KeyIcon size={16} className="text-nb-gray-300" />
}
/>
</div>
<Separator />
<div>
<Label>Redirect / Callback URL</Label>
<HelpText>
Copy this URL to your identity provider configuration
</HelpText>
<Code codeToCopy={redirectUrl} message={copyMessage}>
<Code.Line>{redirectUrl}</Code.Line>
</Code>
</div>
</div>
<ModalFooter className={"items-center"}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
onClick={submit}
disabled={
isDisabled ||
(isEditing
? !permission.identity_providers.update
: !permission.identity_providers.create)
}
>
{isEditing ? (
<>
<SaveIcon size={16} />
Save Changes
</>
) : (
<>
<PlusCircle size={16} />
Add Provider
</>
)}
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}

View File

@@ -0,0 +1,287 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import SquareIcon from "@components/SquareIcon";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import GetStartedTest from "@components/ui/GetStartedTest";
import * as Tabs from "@radix-ui/react-tabs";
import useFetchApi, { useApiCall } from "@utils/api";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import {
FingerprintIcon,
KeyRound,
MoreVertical,
PencilIcon,
PlusCircle,
Trash2,
} from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import {
getSSOIdentityProviderLabelByType,
SSOIdentityProvider,
SSOIdentityProviderType,
} from "@/interfaces/IdentityProvider";
import IdentityProviderModal from "@/modules/settings/IdentityProviderModal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import { idpIcon } from "@/assets/icons/IdentityProviderIcons";
export const idpTypeLabels: Record<SSOIdentityProviderType, string> = {
oidc: "OIDC",
zitadel: "Zitadel",
entra: "Microsoft Entra",
google: "Google",
okta: "Okta",
pocketid: "PocketID",
microsoft: "Microsoft",
authentik: "Authentik",
keycloak: "Keycloak",
};
type ActionCellProps = {
provider: SSOIdentityProvider;
onEdit: (provider: SSOIdentityProvider) => void;
};
function ActionCell({ provider, onEdit }: ActionCellProps) {
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
const deleteRequest = useApiCall<SSOIdentityProvider>(
"/identity-providers/" + provider.id,
);
const { permission } = usePermissions();
const handleDelete = async () => {
const choice = await confirm({
title: `Delete '${provider.name}'?`,
description:
"Are you sure you want to delete this identity provider? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: "Delete Identity Provider",
description: "Identity provider was deleted successfully.",
promise: deleteRequest.del().then(() => {
mutate("/identity-providers");
}),
loadingMessage: "Deleting identity provider...",
});
};
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" className="p-2">
<MoreVertical size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onEdit(provider)}
disabled={!permission.identity_providers.update}
>
<PencilIcon size={14} className="mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDelete}
disabled={!permission.identity_providers.delete}
className="text-red-500 focus:text-red-500"
>
<Trash2 size={14} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export default function IdentityProvidersTab() {
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const { data: providers, isLoading } = useFetchApi<SSOIdentityProvider[]>(
"/identity-providers",
);
const [modalOpen, setModalOpen] = useState(false);
const [editProvider, setEditProvider] = useState<SSOIdentityProvider | null>(
null,
);
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort-identity-providers",
[
{
id: "name",
desc: false,
},
],
);
const handleEdit = (provider: SSOIdentityProvider) => {
setEditProvider(provider);
setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false);
setEditProvider(null);
};
const columns: ColumnDef<SSOIdentityProvider>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableHeader column={column}>Name</DataTableHeader>
),
sortingFn: "text",
cell: ({ row }) => (
<div className="flex items-center gap-3">
{idpIcon(row.original.type) || (
<KeyRound size={16} className="text-nb-gray-400" />
)}
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: "type",
header: ({ column }) => (
<DataTableHeader column={column}>Type</DataTableHeader>
),
cell: ({ row }) => (
<span className="text-nb-gray-400">
{getSSOIdentityProviderLabelByType(row.original.type)}
</span>
),
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => (
<ActionCell provider={row.original} onEdit={handleEdit} />
),
},
];
return (
<Tabs.Content value={"identity-providers"} className={"w-full"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={"Settings"}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=identity-providers"}
label={"Identity Providers"}
icon={<FingerprintIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<div>
<h1>Identity Providers</h1>
<Paragraph>
Configure identity providers for user authentication in your
network.
</Paragraph>
</div>
</div>
</div>
<IdentityProviderModal
open={modalOpen}
key={modalOpen ? 1 : 0}
onClose={handleCloseModal}
provider={editProvider}
/>
<DataTable
isLoading={isLoading}
text={"Identity Providers"}
sorting={sorting}
setSorting={setSorting}
columns={columns}
data={providers}
onRowClick={(row) => handleEdit(row.original)}
searchPlaceholder={"Search by name or type..."}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={<FingerprintIcon size={20} />}
color={"gray"}
size={"large"}
/>
}
title={"Add Identity Provider"}
description={
"Configure an identity provider to enable SSO authentication for your users."
}
button={
<Button
variant={"primary"}
onClick={() => setModalOpen(true)}
disabled={!permission.identity_providers.create}
>
<PlusCircle size={16} />
Add Identity Provider
</Button>
}
/>
}
rightSide={() => (
<>
{providers && providers.length > 0 && (
<Button
variant={"primary"}
className={"ml-auto"}
onClick={() => setModalOpen(true)}
disabled={!permission.identity_providers.create}
>
<PlusCircle size={16} />
Add Identity Provider
</Button>
)}
</>
)}
>
{(table) => (
<>
<DataTableRowsPerPage
table={table}
disabled={!providers || providers.length === 0}
/>
<DataTableRefreshButton
isDisabled={!providers || providers.length === 0}
onClick={() => mutate("/identity-providers")}
/>
</>
)}
</DataTable>
</Tabs.Content>
);
}

View File

@@ -1,4 +1,5 @@
import Button from "@components/Button";
import Code from "@components/Code";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
@@ -14,10 +15,11 @@ import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { IconMailForward } from "@tabler/icons-react";
import { useApiCall } from "@utils/api";
import { cn, validator } from "@utils/helpers";
import { MailIcon, User2 } from "lucide-react";
import { CopyIcon, MailIcon, User2 } from "lucide-react";
import Image from "next/image";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import useCopyToClipboard from "@/hooks/useCopyToClipboard";
import Avatar1 from "@/assets/avatars/009.jpg";
import Avatar2 from "@/assets/avatars/030.jpg";
import Avatar3 from "@/assets/avatars/063.jpg";
@@ -26,33 +28,104 @@ import { Group } from "@/interfaces/Group";
import { Role, User } from "@/interfaces/User";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
import {isNetBirdHosted} from "@utils/netbird";
type Props = {
children: React.ReactNode;
groups?: Group[];
};
const copyMessage = "Password was copied to your clipboard!";
export default function UserInviteModal({ children, groups }: Readonly<Props>) {
const [open, setOpen] = useState(false);
const [successModal, setSuccessModal] = useState(false);
const [createdUser, setCreatedUser] = useState<User>();
const { mutate } = useSWRConfig();
const [, copyToClipboard] = useCopyToClipboard(createdUser?.password);
const handleOnSuccess = () => {
setOpen(false);
const handleOnSuccess = (user: User) => {
if (user.password) {
setCreatedUser(user);
setSuccessModal(true);
} else {
setOpen(false);
}
setTimeout(() => {
mutate("/users?service_user=false");
}, 1000);
};
const handleCopyAndClose = () => {
copyToClipboard(copyMessage).then(() => {
setCreatedUser(undefined);
setSuccessModal(false);
setOpen(false);
});
};
return (
<Modal open={open} onOpenChange={setOpen} key={open ? 1 : 0}>
<ModalTrigger asChild={true}>{children}</ModalTrigger>
<UserInviteModalContent onSuccess={handleOnSuccess} groups={groups} />
</Modal>
<>
<Modal open={open} onOpenChange={setOpen} key={open ? 1 : 0}>
<ModalTrigger asChild={true}>{children}</ModalTrigger>
<UserInviteModalContent onSuccess={handleOnSuccess} groups={groups} />
</Modal>
<Modal
open={successModal}
onOpenChange={(open) => {
if (!open) {
setCreatedUser(undefined);
}
setSuccessModal(open);
setOpen(open);
}}
>
<ModalContent
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
maxWidthClass={"max-w-md"}
className={"mt-20"}
showClose={false}
>
<div className={"pb-6 px-8"}>
<div className={"flex flex-col items-center justify-center gap-3"}>
<div>
<h2 className={"text-2xl text-center mb-2"}>
User created successfully!
</h2>
<Paragraph className={"mt-0 text-sm text-center"}>
This password will not be shown again, so be sure to copy it
and store in a secure location.
</Paragraph>
</div>
</div>
</div>
<div className={"px-8 pb-6"}>
<Code message={copyMessage}>
<Code.Line>{createdUser?.password || ""}</Code.Line>
</Code>
</div>
<ModalFooter className={"items-center"}>
<Button
variant={"primary"}
className={"w-full"}
onClick={handleCopyAndClose}
>
<CopyIcon size={14} />
Copy & Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}
type ModalProps = {
onSuccess: () => void;
onSuccess: (user: User) => void;
groups?: Group[];
};
@@ -85,9 +158,9 @@ export function UserInviteModalContent({
auto_groups: groupIds,
is_service_user: false,
})
.then(() => {
.then((user) => {
mutate("/users?service_user=false");
onSuccess && onSuccess();
onSuccess && onSuccess(user);
}),
loadingMessage: "Sending invite...",
});
@@ -121,10 +194,10 @@ export function UserInviteModalContent({
}
>
<h2 className={"text-lg my-0 leading-[1.5 text-center]"}>
Invite User
{isNetBirdHosted() ? "Invite User" : "Create User"}
</h2>
<Paragraph className={cn("text-sm text-center max-w-xs")}>
Invite a user to your network and set their permissions.
{isNetBirdHosted() ? "Invite a user to your network and set their permissions." : "Create a NetBird user account with email and password."}
</Paragraph>
</div>
@@ -181,7 +254,7 @@ export function UserInviteModalContent({
disabled={isDisabled}
onClick={sendInvite}
>
Send Invitation
{isNetBirdHosted() ? "Send Invitation" : "Create User"}
<IconMailForward size={16} />
</Button>
</ModalFooter>

View File

@@ -15,7 +15,7 @@ import {
Table,
} from "@tanstack/react-table";
import useFetchApi from "@utils/api";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import { isNetBirdHosted } from "@utils/netbird";
import dayjs from "dayjs";
import { ExternalLinkIcon, MailPlus } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
@@ -35,6 +35,7 @@ import UserNameCell from "@/modules/users/table-cells/UserNameCell";
import UserRoleCell from "@/modules/users/table-cells/UserRoleCell";
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
import UserInviteModal from "@/modules/users/UserInviteModal";
import { useAccount } from "@/modules/account/useAccount";
export const UsersTableColumns: ColumnDef<User>[] = [
{
@@ -274,20 +275,27 @@ export const InviteUserButton = ({
groups,
}: InviteUserButtonProps) => {
const { permission } = usePermissions();
const account = useAccount();
if (!show) return null;
// On cloud: always show "Invite User"
// On self-hosted: only show when embedded_idp_enabled is true
const isCloud = isNetBirdHosted();
const embeddedIdpEnabled = account?.settings.embedded_idp_enabled;
if (!isCloud && !embeddedIdpEnabled) return null;
return (
(isLocalDev() || isNetBirdHosted()) && (
<UserInviteModal groups={groups}>
<Button
variant={"primary"}
className={className}
disabled={!permission.users.create}
>
<MailPlus size={16} />
Invite User
</Button>
</UserInviteModal>
)
<UserInviteModal groups={groups}>
<Button
variant={"primary"}
className={className}
disabled={!permission.users.create}
>
<MailPlus size={16} />
{isCloud ? "Invite User" : "Create User"}
</Button>
</UserInviteModal>
);
};

View File

@@ -1,12 +1,37 @@
import { cn, generateColorFromUser } from "@utils/helpers";
import useFetchApi from "@utils/api";
import { Ban, Clock, Cog } from "lucide-react";
import React from "react";
import React, { useMemo } from "react";
import { User } from "@/interfaces/User";
import { SSOIdentityProvider } from "@/interfaces/IdentityProvider";
import { useAccount } from "@/modules/account/useAccount";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/Tooltip";
import { idpIcon } from "@/assets/icons/IdentityProviderIcons";
type Props = {
user: User;
};
export default function UserNameCell({ user }: Readonly<Props>) {
const account = useAccount();
const embeddedIdpEnabled = account?.settings.embedded_idp_enabled;
const { data: identityProviders } = useFetchApi<SSOIdentityProvider[]>(
"/identity-providers",
false,
true,
embeddedIdpEnabled === true,
);
const userIdp = useMemo(() => {
if (!user.idp_id || !identityProviders) return null;
return identityProviders.find((idp) => idp.id === user.idp_id);
}, [user.idp_id, identityProviders]);
const status = user.status;
const isCurrent = user.is_current;
@@ -56,6 +81,20 @@ export default function UserNameCell({ user }: Readonly<Props>) {
{icon}
</div>
)}
{userIdp && status !== "invited" && status !== "blocked" && (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<div className="w-5 h-5 absolute -right-1 -bottom-1 bg-nb-gray-930 rounded-full flex items-center justify-center border-2 border-nb-gray-950 text-nb-gray-50">
{idpIcon(userIdp.type, 14)}
</div>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={5}>
<span className="text-xs">{userIdp.name}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className={"flex flex-col justify-center"}>
<span className={cn("text-base font-medium flex items-center gap-3")}>

View File

@@ -16,10 +16,9 @@ export const getInstallUrl = () => {
};
export const isNetBirdHosted = () => {
return (
window.location.hostname.endsWith(".netbird.io") ||
window.location.hostname.endsWith(".wiretrustee.com")
);
const hostname = window.location.hostname;
if (hostname.includes("selfhosted")) return false;
return hostname.endsWith(".netbird.io") || hostname.endsWith(".wiretrustee.com");
};
export const isLocalDev = () => {

View File

@@ -0,0 +1,54 @@
import loadConfig from "@utils/config";
import {
ApiError,
InstanceStatus,
SetupRequest,
SetupResponse,
} from "@/interfaces/Instance";
const config = loadConfig();
async function unauthenticatedRequest<T>(
method: "GET" | "POST",
endpoint: string,
data?: unknown,
): Promise<T> {
const url = `${config.apiOrigin}/api${endpoint}`;
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: data ? JSON.stringify(data) : undefined,
});
if (!res.ok) {
let error: ApiError;
try {
const errorBody = await res.json();
error = {
code: res.status,
message: errorBody.message || res.statusText,
};
} catch {
error = { code: res.status, message: res.statusText };
}
return Promise.reject(error);
}
// Handle empty response
const text = await res.text();
if (!text) return {} as T;
return JSON.parse(text) as T;
}
export async function fetchInstanceStatus(): Promise<InstanceStatus> {
return unauthenticatedRequest<InstanceStatus>("GET", "/instance");
}
export async function submitSetup(data: SetupRequest): Promise<SetupResponse> {
return unauthenticatedRequest<SetupResponse>("POST", "/setup", data);
}