Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3affa8908f | ||
|
|
52fd984912 | ||
|
|
83e3159ee4 | ||
|
|
bf81aeb02d | ||
|
|
b058e66e32 | ||
|
|
8d6b617cbd | ||
|
|
47db655e9f | ||
|
|
0661cbf9f4 | ||
|
|
240a96fa8b | ||
|
|
43bc069a49 |
1
.github/workflows/build_and_push.yml
vendored
1
.github/workflows/build_and_push.yml
vendored
@@ -2,7 +2,6 @@ name: build and push
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "feature/**"
|
||||
- main
|
||||
tags:
|
||||
- "**"
|
||||
|
||||
5290
package-lock.json
generated
5290
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,6 @@
|
||||
"date-fns": "^2.30.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"flowbite": "^1.8.1",
|
||||
@@ -66,7 +65,7 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^14.2.28",
|
||||
"next": "^14.2.35",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^18.3.1",
|
||||
@@ -90,9 +89,10 @@
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"eslint-config-next": "^14.2.28",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.5",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3"
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -154,7 +154,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
};
|
||||
|
||||
const [tab, setTab] = useState(getInitialTab());
|
||||
const groupDetails = useGroupDetails(group?.id || "");
|
||||
const { groupDetails, isLoading } = useGroupDetails(group?.id || "");
|
||||
|
||||
const peersCount = groupDetails?.peers_count || 0;
|
||||
const usersCount = groupDetails?.users?.length || 0;
|
||||
@@ -266,31 +266,49 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={"users"} className={"pb-8"}>
|
||||
<GroupUsersSection users={groupDetails?.users} />
|
||||
<GroupUsersSection users={groupDetails?.users} isLoading={isLoading} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"peers"} className={"pb-8"}>
|
||||
<GroupPeersSection peers={groupDetails?.peersOfGroup} />
|
||||
<GroupPeersSection
|
||||
peers={groupDetails?.peersOfGroup}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"policies"} className={"pb-8"}>
|
||||
<GroupPoliciesSection policies={groupDetails?.policies} />
|
||||
<GroupPoliciesSection
|
||||
policies={groupDetails?.policies}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<GroupResourcesSection resources={groupDetails?.networkResources} />
|
||||
<GroupResourcesSection
|
||||
resources={groupDetails?.networkResources}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"network-routes"} className={"pb-8"}>
|
||||
<GroupNetworkRoutesSection routes={groupDetails?.routes} />
|
||||
<GroupNetworkRoutesSection
|
||||
routes={groupDetails?.routes}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"nameservers"} className={"pb-8"}>
|
||||
<GroupNameserversSection nameserverGroups={groupDetails?.nameservers} />
|
||||
<GroupNameserversSection
|
||||
nameserverGroups={groupDetails?.nameservers}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"setup-keys"} className={"pb-8"}>
|
||||
<GroupSetupKeysSection setupKeys={groupDetails?.setupKeys} />
|
||||
<GroupSetupKeysSection
|
||||
setupKeys={groupDetails?.setupKeys}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import { Callout } from "@components/Callout";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
@@ -17,6 +16,7 @@ import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
|
||||
import { Callout } from "@components/Callout";
|
||||
|
||||
const NetworkRoutesTable = lazy(
|
||||
() => import("@/modules/route-group/NetworkRoutesTable"),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { cn } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
Barcode,
|
||||
CalendarDays,
|
||||
Cpu,
|
||||
@@ -65,6 +66,7 @@ import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSectio
|
||||
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PeerPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -148,7 +150,7 @@ const PeerGeneralInformation = () => {
|
||||
);
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
initial: peerGroups,
|
||||
initial: peerGroups?.filter((g) => g?.name !== "All"),
|
||||
peer,
|
||||
});
|
||||
|
||||
@@ -237,9 +239,21 @@ const PeerGeneralInformation = () => {
|
||||
</h1>
|
||||
<LoginExpiredBadge loginExpired={peer.login_expired} />
|
||||
</div>
|
||||
<div className={"flex items-center gap-8"}>
|
||||
<Paragraph className={"flex items-center"}>{user?.email}</Paragraph>
|
||||
</div>
|
||||
{(user?.id || user?.email) && (
|
||||
<div className={"flex items-center gap-8"}>
|
||||
<Paragraph className={"flex items-center"}>
|
||||
<Link
|
||||
href={`/team/user?id=${user?.id}`}
|
||||
className={
|
||||
"hover:text-nb-gray-200 transition-all flex items-center gap-1"
|
||||
}
|
||||
>
|
||||
{user?.email || user?.id}
|
||||
<ArrowRightIcon size={14} />
|
||||
</Link>
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex gap-4"}>
|
||||
<Button
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { notify } from "@components/Notification";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
@@ -16,7 +17,15 @@ import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { generateColorFromString } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { Ban, GalleryHorizontalEnd, History, Mail, User2 } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
GalleryHorizontalEnd,
|
||||
History,
|
||||
KeyRoundIcon,
|
||||
Mail,
|
||||
MonitorSmartphoneIcon,
|
||||
User2,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
@@ -33,6 +42,7 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
|
||||
import UserBlockCell from "@/modules/users/table-cells/UserBlockCell";
|
||||
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
|
||||
import { UserPeersSection } from "@/modules/users/UserPeersSection";
|
||||
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
|
||||
|
||||
export default function UserPage() {
|
||||
@@ -80,6 +90,7 @@ type Props = {
|
||||
function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
const router = useRouter();
|
||||
const userRequest = useApiCall<User>("/users");
|
||||
const isServiceUser = !!user?.is_service_user;
|
||||
const { mutate } = useSWRConfig();
|
||||
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
|
||||
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
|
||||
@@ -91,7 +102,6 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
});
|
||||
|
||||
const [role, setRole] = useState(user.role || Role.User);
|
||||
|
||||
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
|
||||
role,
|
||||
selectedGroups,
|
||||
@@ -114,13 +124,24 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
`/${user.id}`,
|
||||
)
|
||||
.then(() => {
|
||||
mutate(`/users?service_user=${user.is_service_user}`);
|
||||
mutate(`/users?service_user=${isServiceUser}`);
|
||||
updateChangesRef([role, selectedGroups]);
|
||||
}),
|
||||
loadingMessage: "Saving changes...",
|
||||
});
|
||||
};
|
||||
|
||||
const isProfilePage = !!user?.is_current && !isServiceUser;
|
||||
const canViewTokens = permission?.pats?.read;
|
||||
const canViewPeers = permission?.peers?.read;
|
||||
|
||||
const showAccessTokens = (user?.is_current || isServiceUser) && canViewTokens;
|
||||
const showPeers = !isServiceUser && canViewPeers;
|
||||
const showTabs = isProfilePage && showPeers && showAccessTokens;
|
||||
const showSeparator = !showTabs;
|
||||
|
||||
const [tab, setTab] = useState(isServiceUser ? "access-tokens" : "peers");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6 mb-4"}>
|
||||
@@ -132,7 +153,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
icon={<TeamIcon size={13} />}
|
||||
/>
|
||||
|
||||
{user.is_service_user ? (
|
||||
{isServiceUser ? (
|
||||
<Breadcrumbs.Item
|
||||
href={"/team/service-users"}
|
||||
label={"Service Users"}
|
||||
@@ -158,7 +179,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
|
||||
}
|
||||
style={
|
||||
user.is_service_user
|
||||
isServiceUser
|
||||
? {
|
||||
color: "white",
|
||||
}
|
||||
@@ -171,13 +192,13 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
}
|
||||
}
|
||||
>
|
||||
{user.is_service_user ? (
|
||||
{isServiceUser ? (
|
||||
<IconSettings2 size={16} />
|
||||
) : (
|
||||
user?.name?.charAt(0) || user?.id?.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<h1 className={"flex items-center gap-3"}>
|
||||
<h1 className={"flex items-center gap-3"} title={user?.id}>
|
||||
{user.name || user.id}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -188,7 +209,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
variant={"default"}
|
||||
className={"w-full"}
|
||||
onClick={() => {
|
||||
user.is_service_user
|
||||
isServiceUser
|
||||
? router.push("/team/service-users")
|
||||
: router.push("/team/users");
|
||||
}}
|
||||
@@ -212,7 +233,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<UserInformationCard user={user} />
|
||||
<div className={"flex flex-col gap-8 w-1/2 "}>
|
||||
{!user.is_service_user && isOwnerOrAdmin && (
|
||||
{!isServiceUser && isOwnerOrAdmin && (
|
||||
<div>
|
||||
<Label>Auto-assigned groups</Label>
|
||||
<HelpText>
|
||||
@@ -238,7 +259,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
<UserRoleSelector
|
||||
value={role}
|
||||
onChange={setRole}
|
||||
hideOwner={user.is_service_user}
|
||||
hideOwner={isServiceUser}
|
||||
currentUser={user}
|
||||
disabled={isLoggedInUser || !permission.users.update}
|
||||
/>
|
||||
@@ -248,38 +269,65 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(user.is_current || user.is_service_user) && permission.pats.read && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className={"px-8 py-6"}>
|
||||
<div className={"max-w-6xl"}>
|
||||
<div className={"flex justify-between items-center"}>
|
||||
<div>
|
||||
<h2>Access Tokens</h2>
|
||||
<Paragraph>
|
||||
Access tokens give access to NetBird API.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"inline-flex gap-4 justify-end"}>
|
||||
{showSeparator && <Separator />}
|
||||
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"} hidden={!showTabs}>
|
||||
{showPeers && (
|
||||
<TabsTrigger value={"peers"}>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
Peers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{showAccessTokens && (
|
||||
<TabsTrigger value={"access-tokens"}>
|
||||
<KeyRoundIcon size={16} />
|
||||
Access Tokens
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
{showPeers && (
|
||||
<TabsContent value={"peers"} className={"pb-8"}>
|
||||
<UserPeersSection user={user} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{showAccessTokens && (
|
||||
<TabsContent value={"access-tokens"} className={"pb-8"}>
|
||||
<div className={"px-8"}>
|
||||
<div className={"max-w-6xl"}>
|
||||
<div className={"flex justify-between items-center"}>
|
||||
<div>
|
||||
<CreateAccessTokenModal user={user}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"access-token-open-modal"}
|
||||
disabled={!permission.pats.create}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Create Access Token
|
||||
</Button>
|
||||
</CreateAccessTokenModal>
|
||||
<h2>Access Tokens</h2>
|
||||
<Paragraph>
|
||||
Access tokens give access to NetBird API.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"inline-flex gap-4 justify-end"}>
|
||||
<div>
|
||||
<CreateAccessTokenModal user={user}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"access-token-open-modal"}
|
||||
disabled={!permission.pats.create}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Create Access Token
|
||||
</Button>
|
||||
</CreateAccessTokenModal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AccessTokensTable user={user} />
|
||||
</div>
|
||||
<AccessTokensTable user={user} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { notify } from "@components/Notification";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { IconCircleX } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
@@ -20,6 +19,8 @@ import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetbirdSSHProtocolSupported } from "@utils/version";
|
||||
|
||||
export default function RDPPage() {
|
||||
const { peerId } = useRDPQueryParams();
|
||||
@@ -84,7 +85,12 @@ function RDPSession({ peer }: Props) {
|
||||
try {
|
||||
setCredentials(rdpCredentials);
|
||||
setIsNetBirdConnecting(true);
|
||||
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
await client.connectTemporary(peer.id, [
|
||||
`${protocol}/${rdpCredentials.port}`,
|
||||
]);
|
||||
setIsNetBirdConnecting(false);
|
||||
} catch (error) {
|
||||
sendErrorNotification(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { PageNotFound } from "@components/ui/PageNotFound";
|
||||
import useFetchApi, { ErrorResponse } from "@utils/api";
|
||||
import { isNativeSSHSupported } from "@utils/version";
|
||||
import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
@@ -13,6 +12,10 @@ import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
import {
|
||||
isNativeSSHSupported,
|
||||
isNetbirdSSHProtocolSupported,
|
||||
} from "@utils/version";
|
||||
|
||||
export default function SSHPage() {
|
||||
const { peerId, username, port } = useSSHQueryParams();
|
||||
@@ -88,7 +91,10 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
connected.current = false;
|
||||
try {
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const rules = [`tcp/${aclPort}`];
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
const rules = [`${protocol}/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
await ssh({
|
||||
hostname: peer.ip,
|
||||
@@ -108,9 +114,13 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
if (!peer.id) return;
|
||||
if (connected.current) return;
|
||||
connected.current = true;
|
||||
|
||||
try {
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const rules = [`tcp/${aclPort}`];
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
const rules = [`${protocol}/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
const res = await ssh({
|
||||
hostname: peer.ip,
|
||||
@@ -121,7 +131,7 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
sshConnectedOnce.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Connection failed:", error);
|
||||
console.error("Connection error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
@@ -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
8
src/app/setup/layout.tsx
Normal 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;
|
||||
22
src/app/setup/page.tsx
Normal file
22
src/app/setup/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import InstanceSetupWizard from "@/modules/instance-setup/InstanceSetupWizard";
|
||||
import { useInstanceSetup } from "@/contexts/InstanceSetupProvider";
|
||||
import { useRouter } from "next/navigation";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function SetupPage() {
|
||||
const { setupRequired, loading } = useInstanceSetup();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !setupRequired) router.replace("/peers");
|
||||
}, [loading, setupRequired]);
|
||||
|
||||
return loading || !setupRequired ? (
|
||||
<FullScreenLoading />
|
||||
) : (
|
||||
<InstanceSetupWizard />
|
||||
);
|
||||
}
|
||||
28
src/assets/icons/AuthentikIcon.tsx
Normal file
28
src/assets/icons/AuthentikIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/assets/icons/IdentityProviderIcons.tsx
Normal file
30
src/assets/icons/IdentityProviderIcons.tsx
Normal 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];
|
||||
};
|
||||
19
src/assets/icons/JumpcloudIcon.tsx
Normal file
19
src/assets/icons/JumpcloudIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function JumpcloudIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
width="167"
|
||||
height="82"
|
||||
viewBox="0 0 167 82"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M166.911 58.3592C166.911 64.3815 164.519 70.1571 160.26 74.4155C156.002 78.6739 150.226 81.0662 144.204 81.0662H137.961C137.31 73.4972 129.5 67.0612 118.46 64.0722C121.244 61.3253 123.148 57.8124 123.931 53.9803C124.713 50.1482 124.338 46.17 122.854 42.5515C121.369 38.933 118.842 35.8378 115.594 33.6594C112.345 31.481 108.522 30.3178 104.611 30.3178C100.7 30.3178 96.8772 31.481 93.6289 33.6594C90.3805 35.8378 87.8534 38.933 86.3689 42.5515C84.8843 46.17 84.5094 50.1482 85.2918 53.9803C86.0743 57.8124 87.9786 61.3253 90.7628 64.0722C85.5111 65.3278 80.6301 67.8055 76.5167 71.3037C73.9207 69.8152 71.1411 68.6726 68.2487 67.9049C70.6422 65.5587 72.2829 62.5529 72.9614 59.2707C73.6399 55.9884 73.3255 52.5784 72.0584 49.4755C70.7913 46.3726 68.6288 43.7174 65.8467 41.8484C63.0646 39.9793 59.7888 38.9812 56.4372 38.9812C53.0855 38.9812 49.8098 39.9793 47.0277 41.8484C44.2455 43.7174 42.0831 46.3726 40.816 49.4755C39.5488 52.5784 39.2345 55.9884 39.913 59.2707C40.5915 62.5529 42.2321 65.5587 44.6257 67.9049C35.9237 70.3154 29.5841 75.1364 28.2342 80.9698H21.991C16.0936 80.7777 10.502 78.2999 6.39821 74.0603C2.2944 69.8206 0 64.1513 0 58.2508C0 52.3503 2.2944 46.681 6.39821 42.4413C10.502 38.2016 16.0936 35.7238 21.991 35.5317C24.8814 35.5419 27.7438 36.0981 30.4278 37.1709C32.2478 33.2162 35.1686 29.8695 38.8407 27.5312C42.5128 25.1928 46.7807 23.9618 51.1341 23.9854C51.6885 23.9854 52.2429 23.9854 52.7732 23.9854C53.9093 18.1059 56.8018 12.7093 61.0689 8.50798C65.336 4.30669 70.7769 1.49837 76.6733 0.453829C82.5698 -0.590709 88.6443 0.177651 94.095 2.65746C99.546 5.13728 104.116 9.21191 107.203 14.3434C110.733 13.2708 114.463 13.023 118.104 13.6193C121.746 14.2155 125.202 15.6397 128.206 17.7822C131.21 19.9247 133.682 22.7283 135.432 25.977C137.182 29.2257 138.162 32.8326 138.298 36.52C141.665 35.6031 145.198 35.4762 148.622 36.1492C152.046 36.8222 155.269 38.277 158.038 40.4001C160.808 42.5233 163.049 45.2574 164.588 48.3892C166.127 51.5211 166.922 54.9661 166.911 58.4557V58.3592Z"
|
||||
fill="#4CC2BF"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
88
src/assets/icons/KeycloakIcon.tsx
Normal file
88
src/assets/icons/KeycloakIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/assets/icons/MicrosoftIcon.tsx
Normal file
16
src/assets/icons/MicrosoftIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/assets/icons/OIDCIcon.tsx
Normal file
27
src/assets/icons/OIDCIcon.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function OIDCIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
width="173"
|
||||
height="174"
|
||||
viewBox="0 0 173 174"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M76.3945 173.48L103.325 154.065L102.072 0L76.3945 20.041V173.48Z"
|
||||
fill="#FF8E00"
|
||||
/>
|
||||
<path
|
||||
d="M76.7077 173.48C-24.0221 157.466 -26.8926 69.7689 76.0814 50.7288L76.3945 68.8909C3.35034 81.0694 12.6045 146.598 76.3945 156.257L76.7077 173.48Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M103.011 68.2646C115.468 68.3493 126.32 74.0515 137.144 79.8508L121.174 91.7502H172.216L172.529 56.9916L156.558 68.8909C140.397 60.7278 125.542 50.9315 103.011 50.7288V68.2646Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
17
src/assets/icons/PocketIdIcon.tsx
Normal file
17
src/assets/icons/PocketIdIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/assets/icons/ZitadelIcon.tsx
Normal file
32
src/assets/icons/ZitadelIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
src/assets/onboarding/acl.png
Normal file
BIN
src/assets/onboarding/acl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
src/assets/onboarding/activity.png
Normal file
BIN
src/assets/onboarding/activity.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 261 KiB |
BIN
src/assets/onboarding/posture.png
Normal file
BIN
src/assets/onboarding/posture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
@@ -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 />;
|
||||
};
|
||||
|
||||
@@ -32,6 +32,10 @@ const variants = cva("", {
|
||||
green: ["bg-green-950 border-green-500 border text-green-400"],
|
||||
netbird: ["bg-netbird-950 border-netbird-500 border text-netbird-500"],
|
||||
},
|
||||
size: {
|
||||
default: "text-[0.75rem] py-1.5 px-3",
|
||||
xs: "text-[0.6rem] py-[0.3rem] px-2",
|
||||
},
|
||||
hover: {
|
||||
none: [],
|
||||
blue: ["hover:bg-sky-200"],
|
||||
@@ -42,7 +46,7 @@ const variants = cva("", {
|
||||
red: ["hover:bg-red-950/40"],
|
||||
gray: ["hover:bg-nb-gray-900"],
|
||||
grayer: ["hover:bg-nb-gray-900"],
|
||||
"gray-ghost": ["hover:bg-nb-gray-900"],
|
||||
"gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"],
|
||||
green: ["hover:bg-green-950/50"],
|
||||
netbird: ["hover:bg-netbird-950/50"],
|
||||
},
|
||||
@@ -53,6 +57,7 @@ export default function Badge({
|
||||
children,
|
||||
className,
|
||||
variant = "blue",
|
||||
size = "default",
|
||||
useHover = false,
|
||||
disabled = false,
|
||||
...props
|
||||
@@ -60,8 +65,8 @@ export default function Badge({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 cursor-inherit whitespace-nowrap rounded-md text-[12px] py-1.5 px-3 font-normal flex gap-1.5 items-center justify-center transition-all",
|
||||
variants({ variant, hover: useHover ? variant : "none" }),
|
||||
"relative z-10 cursor-inherit whitespace-nowrap rounded-md font-normal flex gap-1.5 items-center justify-center transition-all",
|
||||
variants({ variant, hover: useHover ? variant : "none", size }),
|
||||
disabled && "cursor-not-allowed opacity-50 select-none",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -81,7 +81,7 @@ const menuItemVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-gray-400 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
|
||||
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-nb-gray-300 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
|
||||
danger:
|
||||
"dark:focus:bg-red-900/20 dark:focus:text-red-500 dark:text-red-500",
|
||||
},
|
||||
|
||||
43
src/components/HoverCard.tsx
Normal file
43
src/components/HoverCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { TooltipVariants, tooltipVariants } from "./Tooltip";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> &
|
||||
TooltipVariants
|
||||
>(
|
||||
(
|
||||
{
|
||||
className = "px-4 py-2.5",
|
||||
sideOffset = 7,
|
||||
side = "top",
|
||||
variant = "default",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<HoverCardPrimitive.Portal>
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(tooltipVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
</HoverCardPrimitive.Content>
|
||||
</HoverCardPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger };
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1 inline-block dark:text-nb-gray-200 flex items-center gap-2",
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-200 flex items-center gap-2",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
|
||||
@@ -42,8 +42,8 @@ import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
|
||||
const groupsSearchPredicate = (item: Group, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
@@ -526,7 +526,7 @@ export function PeerGroupSelector({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
<div className={"flex items-center gap-4"}>
|
||||
{option?.id && showRoutes && (
|
||||
<AccessControlGroupCount group_id={option.id} />
|
||||
)}
|
||||
@@ -535,19 +535,12 @@ export function PeerGroupSelector({
|
||||
<ResourcesCounter group={option} />
|
||||
)}
|
||||
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
{!users ? (
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<MonitorSmartphoneIcon
|
||||
size={14}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
{peerCount} Peer(s)
|
||||
</div>
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
) : (
|
||||
<UsersCounter
|
||||
group={option}
|
||||
@@ -555,7 +548,6 @@ export function PeerGroupSelector({
|
||||
selected={isSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -671,7 +663,14 @@ const UsersCounter = ({
|
||||
users?.filter((user) => user.auto_groups.includes(group.id as string)) ||
|
||||
[];
|
||||
|
||||
if (usersOfGroup.length === 0) return null;
|
||||
if (usersOfGroup.length === 0)
|
||||
return (
|
||||
<span
|
||||
className={"group-hover/user-stack:text-nb-gray-200 text-nb-gray-300"}
|
||||
>
|
||||
0 User(s)
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<HorizontalUsersStack
|
||||
@@ -686,6 +685,31 @@ const UsersCounter = ({
|
||||
);
|
||||
};
|
||||
|
||||
const PeerCounter = ({
|
||||
group,
|
||||
showResourceCounter,
|
||||
}: {
|
||||
group: Group;
|
||||
showResourceCounter?: boolean;
|
||||
}) => {
|
||||
const peerCount = group.peers?.length ?? group?.peers_count ?? 0;
|
||||
const resourcesCount = group?.resources_count ?? 0;
|
||||
const hidePeerCounter =
|
||||
showResourceCounter && peerCount === 0 && resourcesCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2",
|
||||
hidePeerCounter && "hidden",
|
||||
)}
|
||||
>
|
||||
<MonitorSmartphoneIcon size={14} className={"shrink-0"} />
|
||||
{peerCount} Peer(s)
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourcesCounter = ({ group }: { group: Group }) => {
|
||||
return group?.resources_count && group.resources_count > 0 ? (
|
||||
<div
|
||||
|
||||
@@ -139,7 +139,11 @@ export function PortSelector({
|
||||
<Badge
|
||||
key={x}
|
||||
variant={"gray"}
|
||||
onClick={() => toggle(x)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(x);
|
||||
}}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{x}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,38 +39,43 @@ const Tabs = React.forwardRef<
|
||||
Tabs.displayName = TabsPrimitive.Root.displayName;
|
||||
|
||||
type TabListProps = {
|
||||
hidden?: boolean;
|
||||
justify?: "start" | "end" | "center" | "between";
|
||||
};
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & TabListProps
|
||||
>(({ className, justify = "center", ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-nowrap text-neutral-500 dark:text-nb-gray-400 w-full relative",
|
||||
className,
|
||||
justify == "center" && "justify-center justify-items-end",
|
||||
justify == "start" && "justify-start",
|
||||
justify == "end" && "justify-end",
|
||||
justify == "between" && "justify-between",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"absolute left-0 dark:bg-nb-gray-900 bg-nb-gray-100 w-full h-[1px] bottom-0 z-0"
|
||||
}
|
||||
/>
|
||||
<ScrollArea>
|
||||
<div className={"relative z-[1] flex flex-nowrap w-full "}>
|
||||
{props.children}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</TabsPrimitive.List>
|
||||
));
|
||||
>(({ className, justify = "center", hidden = false, ...props }, ref) => {
|
||||
return (
|
||||
!hidden && (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-nowrap text-neutral-500 dark:text-nb-gray-400 w-full relative",
|
||||
className,
|
||||
justify == "center" && "justify-center justify-items-end",
|
||||
justify == "start" && "justify-start",
|
||||
justify == "end" && "justify-end",
|
||||
justify == "between" && "justify-between",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"absolute left-0 dark:bg-nb-gray-900 bg-nb-gray-100 w-full h-[1px] bottom-0 z-0"
|
||||
}
|
||||
/>
|
||||
<ScrollArea>
|
||||
<div className={"relative z-[1] flex flex-nowrap w-full "}>
|
||||
{props.children}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</TabsPrimitive.List>
|
||||
)
|
||||
);
|
||||
});
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
|
||||
@@ -22,14 +22,14 @@ export const tooltipVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"bg-white dark:bg-nb-gray-940",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-930",
|
||||
"bg-nb-gray-940",
|
||||
"text-neutral-50",
|
||||
"border-neutral-200 border-nb-gray-930",
|
||||
],
|
||||
lighter: [
|
||||
"bg-white dark:bg-nb-gray-920",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-900",
|
||||
"bg-nb-gray-920",
|
||||
"text-neutral-50",
|
||||
"border-neutral-200 border-nb-gray-900",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from "@utils/helpers";
|
||||
import { XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -17,6 +18,9 @@ type Props = {
|
||||
maxChars?: number;
|
||||
maxWidth?: string;
|
||||
hideTooltip?: boolean;
|
||||
textClassName?: string;
|
||||
redirectGroupTab?: string;
|
||||
redirectToGroupPage?: boolean;
|
||||
};
|
||||
|
||||
export default function GroupBadge({
|
||||
@@ -29,19 +33,33 @@ export default function GroupBadge({
|
||||
maxChars = 20,
|
||||
maxWidth,
|
||||
hideTooltip = false,
|
||||
textClassName,
|
||||
redirectGroupTab,
|
||||
redirectToGroupPage = false,
|
||||
}: Readonly<Props>) {
|
||||
const isNew = !group?.id;
|
||||
const router = useRouter();
|
||||
|
||||
const handleGroupPageRedirect = () => {
|
||||
if (!group?.id) return;
|
||||
let redirectUrl = `/group?id=${group.id}`;
|
||||
if (redirectGroupTab) {
|
||||
redirectUrl += `&tab=${encodeURIComponent(redirectGroupTab)}`;
|
||||
}
|
||||
router.push(redirectUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={group.id ?? group.name}
|
||||
useHover={true}
|
||||
useHover={!!onClick || redirectToGroupPage}
|
||||
data-cy={"group-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn("transition-all group whitespace-nowrap", className)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
if (redirectToGroupPage) handleGroupPageRedirect();
|
||||
}}
|
||||
>
|
||||
<GroupBadgeIcon id={group?.id} issued={group?.issued} />
|
||||
@@ -49,6 +67,7 @@ export default function GroupBadge({
|
||||
text={group?.name || ""}
|
||||
maxChars={maxChars}
|
||||
maxWidth={maxWidth}
|
||||
className={textClassName}
|
||||
hideTooltip={hideTooltip}
|
||||
/>
|
||||
{children}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { FolderGit2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import EntraIcon from "@/assets/icons/EntraIcon";
|
||||
import GoogleIcon from "@/assets/icons/GoogleIcon";
|
||||
import JumpcloudIcon from "@/assets/icons/JumpcloudIcon";
|
||||
import JWTIcon from "@/assets/icons/JWTIcon";
|
||||
import OIDCIcon from "@/assets/icons/OIDCIcon";
|
||||
import OktaIcon from "@/assets/icons/OktaIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { GroupIssued } from "@/interfaces/Group";
|
||||
@@ -20,8 +22,14 @@ export const GroupBadgeIcon = ({
|
||||
const { groups } = useGroups();
|
||||
const group = groups?.find((g) => g.id === id);
|
||||
|
||||
const { isAzureGroup, isGoogleGroup, isOktaGroup, isJWTGroup } =
|
||||
useGroupIdentification({ id, issued: issued ?? group?.issued });
|
||||
const {
|
||||
isAzureGroup,
|
||||
isGoogleGroup,
|
||||
isOktaGroup,
|
||||
isJWTGroup,
|
||||
isJumpcloudGroup,
|
||||
isOIDCGroup,
|
||||
} = useGroupIdentification({ id, issued: issued ?? group?.issued });
|
||||
|
||||
if (isGoogleGroup)
|
||||
return <GoogleIcon size={size - 1} className={"shrink-0 mr-0.5"} />;
|
||||
@@ -29,6 +37,10 @@ export const GroupBadgeIcon = ({
|
||||
return <EntraIcon size={size + 1} className={"shrink-0 mr-0.5"} />;
|
||||
if (isOktaGroup)
|
||||
return <OktaIcon size={size - 1} className={"shrink-0 mr-0.5"} />;
|
||||
if (isJumpcloudGroup)
|
||||
return <JumpcloudIcon size={size + 2} className={"shrink-0 mr-0.5"} />;
|
||||
if (isOIDCGroup)
|
||||
return <OIDCIcon size={size} className={"shrink-0 mr-0.5"} />;
|
||||
if (isJWTGroup) return <JWTIcon size={size} className={"shrink-0"} />;
|
||||
|
||||
return <FolderGit2 size={size} className={"shrink-0"} />;
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/Tooltip";
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@components/HoverCard";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import PeerBadge from "@components/ui/PeerBadge";
|
||||
import PeerCountBadge from "@components/ui/PeerCountBadge";
|
||||
import ResourceCountBadge from "@components/ui/ResourceCountBadge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowRightIcon, PencilLineIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
|
||||
type Props = {
|
||||
groups: Group[];
|
||||
@@ -21,6 +23,9 @@ type Props = {
|
||||
description?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
showResources?: boolean;
|
||||
redirectGroupTab?: string;
|
||||
showUsers?: boolean;
|
||||
};
|
||||
|
||||
export default function MultipleGroups({
|
||||
@@ -29,6 +34,9 @@ export default function MultipleGroups({
|
||||
description = "Use groups to control what this peer can access",
|
||||
onClick,
|
||||
className,
|
||||
showResources = false,
|
||||
showUsers = false,
|
||||
redirectGroupTab,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
@@ -45,13 +53,9 @@ export default function MultipleGroups({
|
||||
const otherGroups = orderedGroups.length > 0 ? orderedGroups.slice(1) : [];
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
disableHoverableContent={false}
|
||||
delayDuration={200}
|
||||
skipDelayDuration={200}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<div className={"flex"}>
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger>
|
||||
<div
|
||||
className={cn("inline-flex items-center gap-2 z-0", className)}
|
||||
data-cy={"multiple-groups"}
|
||||
@@ -78,9 +82,9 @@ export default function MultipleGroups({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</HoverCardTrigger>
|
||||
{orderedGroups && orderedGroups.length > 0 && (
|
||||
<TooltipContent
|
||||
<HoverCardContent
|
||||
className={"p-0"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -102,19 +106,31 @@ export default function MultipleGroups({
|
||||
"flex gap-2 items-center justify-between w-full"
|
||||
}
|
||||
>
|
||||
<GroupBadge group={group}></GroupBadge>
|
||||
<GroupBadge
|
||||
group={group}
|
||||
className={"py-0"}
|
||||
textClassName={"py-1.5"}
|
||||
redirectToGroupPage={true}
|
||||
redirectGroupTab={redirectGroupTab}
|
||||
></GroupBadge>
|
||||
<ArrowRightIcon size={14} />
|
||||
<PeerBadge> {group.peers_count} Peer(s)</PeerBadge>
|
||||
{showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
) : showUsers ? (
|
||||
<UserCountStack group={group} />
|
||||
) : (
|
||||
<PeerCountBadge group={group} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TooltipContent>
|
||||
</HoverCardContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</HoverCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,3 +145,17 @@ export const TransparentEditIconButton = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserCountStack = ({ group }: { group: Group }) => {
|
||||
const { users } = useUsers();
|
||||
const usersOfGroup =
|
||||
users?.filter((user) => user.auto_groups.includes(group.id as string)) ||
|
||||
[];
|
||||
return (
|
||||
<HorizontalUsersStack
|
||||
users={usersOfGroup}
|
||||
side={"right"}
|
||||
isAllGroup={group?.name === "All"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
64
src/components/ui/PeerCountBadge.tsx
Normal file
64
src/components/ui/PeerCountBadge.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Badge, { BadgeVariants } from "@components/Badge";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
import { MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import ResourceCountBadge from "@components/ui/ResourceCountBadge";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
export default function PeerCountBadge({
|
||||
group,
|
||||
variant = "gray",
|
||||
className,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const { dropdownOptions } = useGroups();
|
||||
|
||||
const currentGroup = useMemo(() => {
|
||||
return dropdownOptions?.find((g) => g.name === group?.name);
|
||||
}, [group, dropdownOptions]);
|
||||
|
||||
const peerCount = useMemo(() => {
|
||||
let peerCount = currentGroup?.peers_count ?? 0;
|
||||
let countedPeers = currentGroup?.peers?.length ?? 0;
|
||||
if (peerCount !== countedPeers) {
|
||||
peerCount = countedPeers;
|
||||
}
|
||||
return peerCount;
|
||||
}, [currentGroup]);
|
||||
|
||||
const canRedirect = !!group?.id && group?.name !== "All";
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (canRedirect) router.push(`/group?id=${group?.id}&tab=peers`);
|
||||
};
|
||||
|
||||
const resourcesCount = group?.resources_count ?? 0;
|
||||
const showResources = resourcesCount > 0 && peerCount === 0;
|
||||
|
||||
return showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
) : (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
className,
|
||||
"px-3 gap-2 whitespace-nowrap",
|
||||
canRedirect && "cursor-pointer",
|
||||
)}
|
||||
onClick={onClick}
|
||||
useHover={canRedirect}
|
||||
>
|
||||
<MonitorSmartphoneIcon size={12} />
|
||||
{singularize("Peers", peerCount, true)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Badge from "@components/Badge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import LongArrowLeftIcon from "@/assets/icons/LongArrowLeftIcon";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { PolicyRuleResource, Protocol } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
onChange: (value: Direction) => void;
|
||||
className?: string;
|
||||
destinationResource?: PolicyRuleResource;
|
||||
protocol?: Protocol;
|
||||
};
|
||||
|
||||
export type Direction = "bi" | "in" | "out";
|
||||
@@ -20,8 +21,10 @@ export default function PolicyDirection({
|
||||
onChange,
|
||||
className,
|
||||
destinationResource,
|
||||
protocol,
|
||||
}: Readonly<Props>) {
|
||||
const toggleDirection = () => {
|
||||
if (protocol === "netbird-ssh") return;
|
||||
if (value == "bi") {
|
||||
onChange("in");
|
||||
} else {
|
||||
@@ -30,9 +33,13 @@ export default function PolicyDirection({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (protocol === "netbird-ssh") {
|
||||
onChange("in");
|
||||
return;
|
||||
}
|
||||
if (disabled) onChange("bi");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [disabled]);
|
||||
}, [disabled, protocol]);
|
||||
|
||||
const isNetworkResource =
|
||||
!!destinationResource && destinationResource?.type !== "peer";
|
||||
@@ -67,7 +74,8 @@ export default function PolicyDirection({
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col gap-2 mt-[23px] cursor-pointer select-none",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
(disabled || protocol === "netbird-ssh") &&
|
||||
"opacity-50 pointer-events-none",
|
||||
"hover:opacity-80 transition-all",
|
||||
className,
|
||||
)}
|
||||
|
||||
33
src/components/ui/ResourceCountBadge.tsx
Normal file
33
src/components/ui/ResourceCountBadge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Badge, { BadgeVariants } from "@components/Badge";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
import { LayersIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
export default function ResourceCountBadge({ group }: Props) {
|
||||
const router = useRouter();
|
||||
const hasId = !!group?.id;
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (hasId) router.push(`/group?id=${group?.id}&tab=resources`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn("px-3 gap-2 whitespace-nowrap", hasId && "cursor-pointer")}
|
||||
variant={"gray"}
|
||||
onClick={onClick}
|
||||
useHover={hasId}
|
||||
>
|
||||
<LayersIcon size={12} />
|
||||
{singularize("Resources", group?.resources_count, true)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/Tooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
@@ -55,38 +55,28 @@ export default function TruncatedText({
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard.Root
|
||||
openDelay={650}
|
||||
closeDelay={100}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<HoverCard.Trigger asChild={true}>
|
||||
<Tooltip delayDuration={650} open={open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<div className="w-full min-w-0 inline-block" style={containerStyle}>
|
||||
<div ref={contentRef} className={cn(className, "truncate")}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onMouseEnter={() => setOpen(false)}
|
||||
alignOffset={20}
|
||||
sideOffset={4}
|
||||
className={cn(
|
||||
"z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50",
|
||||
className,
|
||||
"px-3 py-1.5",
|
||||
)}
|
||||
>
|
||||
<div className="text-neutral-300 flex flex-col gap-1">
|
||||
<div className="max-w-xs break-all whitespace-normal text-xs">
|
||||
{text}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
alignOffset={20}
|
||||
sideOffset={4}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(className, "px-3 py-1.5")}
|
||||
>
|
||||
<div className="text-neutral-300 flex flex-col gap-1">
|
||||
<div className="max-w-xs break-all whitespace-normal text-xs">
|
||||
{text}
|
||||
</div>
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export type HubspotFormField = {
|
||||
objectTypeId?: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const AnalyticsContext = React.createContext(
|
||||
{} as {
|
||||
initialized: boolean;
|
||||
|
||||
@@ -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,
|
||||
|
||||
93
src/contexts/InstanceSetupProvider.tsx
Normal file
93
src/contexts/InstanceSetupProvider.tsx
Normal 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 = ["/install"];
|
||||
const shouldBypass = bypassRoutes.includes(pathname) || isOIDCCallback();
|
||||
|
||||
// Skip setup check for NetBird hosted (cloud) deployments
|
||||
const isCloud = isNetBirdHosted();
|
||||
const isSetupPage = pathname === "/setup";
|
||||
|
||||
// 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 && !isSetupPage) {
|
||||
router.replace("/setup");
|
||||
}
|
||||
}, [setupRequired, shouldBypass, router, isSetupPage]);
|
||||
|
||||
// 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 && !isSetupPage) {
|
||||
return <FullScreenLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InstanceSetupContext.Provider value={{ setupRequired, loading }}>
|
||||
{children}
|
||||
</InstanceSetupContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -139,7 +139,10 @@ export default function PeerProvider({
|
||||
open={sshInstructionsModal}
|
||||
onOpenChange={setSSHInstructionsModal}
|
||||
peer={peer}
|
||||
onSuccess={() => toggleSSH(true)}
|
||||
onSuccess={() => {
|
||||
mutate(`/peers/${peer.id}`);
|
||||
setSSHInstructionsModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -16,11 +18,15 @@ const PoliciesContext = React.createContext(
|
||||
message?: string,
|
||||
) => void;
|
||||
createPolicy: (policy: Policy) => Promise<Policy>;
|
||||
openEditPolicyModal: (policy: Policy, tab?: string) => void;
|
||||
},
|
||||
);
|
||||
|
||||
export default function PoliciesProvider({ children }: Props) {
|
||||
const request = useApiCall<Policy>("/policies");
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
|
||||
const [initialPolicyTab, setInitialPolicyTab] = useState("");
|
||||
|
||||
const createPolicy = async (policy: Policy) => request.post(policy);
|
||||
|
||||
@@ -56,9 +62,34 @@ export default function PoliciesProvider({ children }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const openEditPolicyModal = (policy: Policy, tab?: string) => {
|
||||
setCurrentPolicy(policy);
|
||||
tab && setInitialPolicyTab(tab);
|
||||
setPolicyModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<PoliciesContext.Provider value={{ updatePolicy, createPolicy }}>
|
||||
<PoliciesContext.Provider
|
||||
value={{ updatePolicy, createPolicy, openEditPolicyModal }}
|
||||
>
|
||||
{children}
|
||||
<Modal
|
||||
open={policyModal}
|
||||
onOpenChange={(state) => {
|
||||
setPolicyModal(state);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
>
|
||||
<AccessControlModalContent
|
||||
key={policyModal ? "1" : "0"}
|
||||
policy={currentPolicy}
|
||||
initialTab={initialPolicyTab}
|
||||
onSuccess={async (p) => {
|
||||
setPolicyModal(false);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</PoliciesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -22,5 +22,13 @@ export interface Account {
|
||||
dns_domain: string;
|
||||
network_range?: string;
|
||||
lazy_connection_enabled: boolean;
|
||||
embedded_idp_enabled?: boolean;
|
||||
auto_update_version: string;
|
||||
};
|
||||
onboarding?: AccountOnboarding;
|
||||
}
|
||||
|
||||
export interface AccountOnboarding {
|
||||
onboarding_flow_pending: boolean;
|
||||
signup_form_pending: boolean;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
19
src/interfaces/Instance.ts
Normal file
19
src/interfaces/Instance.ts
Normal 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;
|
||||
}
|
||||
@@ -24,9 +24,24 @@ export interface Peer {
|
||||
login_expiration_enabled: boolean;
|
||||
inactivity_expiration_enabled: boolean;
|
||||
approval_required: boolean;
|
||||
disapproval_reason?: string;
|
||||
city_name: string;
|
||||
country_code: string;
|
||||
connection_ip: string;
|
||||
serial_number: string;
|
||||
ephemeral: boolean;
|
||||
local_flags?: PeerLocalFlags;
|
||||
}
|
||||
|
||||
export interface PeerLocalFlags {
|
||||
block_inbound: boolean;
|
||||
block_lan_access: boolean;
|
||||
disable_client_routes: boolean;
|
||||
disable_dns: boolean;
|
||||
disable_firewall: boolean;
|
||||
disable_server_routes: boolean;
|
||||
lazy_connection_enabled: boolean;
|
||||
rosenpass_enabled: boolean;
|
||||
rosenpass_permissive: boolean;
|
||||
server_ssh_allowed: boolean;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Permissions {
|
||||
settings: Permission;
|
||||
accounts: Permission;
|
||||
billing: Permission;
|
||||
identity_providers: Permission;
|
||||
|
||||
edr: Permission;
|
||||
event_streaming: Permission;
|
||||
|
||||
@@ -25,8 +25,11 @@ export interface PolicyRule {
|
||||
port_ranges?: PortRange[];
|
||||
sourceResource?: PolicyRuleResource;
|
||||
destinationResource?: PolicyRuleResource;
|
||||
authorized_groups?: AuthorizedGroups;
|
||||
}
|
||||
|
||||
export type AuthorizedGroups = Record<string, string[]>; // group_id, local machine usernames
|
||||
|
||||
export interface PortRange {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -37,4 +40,4 @@ export interface PolicyRuleResource {
|
||||
type?: "domain" | "host" | "subnet" | "peer";
|
||||
}
|
||||
|
||||
export type Protocol = "all" | "tcp" | "udp" | "icmp";
|
||||
export type Protocol = "all" | "tcp" | "udp" | "icmp" | "netbird-ssh";
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface User {
|
||||
pending_approval?: boolean;
|
||||
last_login?: Date;
|
||||
permissions: Permissions;
|
||||
password?: string;
|
||||
idp_id?: string;
|
||||
}
|
||||
|
||||
export enum Role {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useOidcUser } from "@axa-fr/react-oidc";
|
||||
import Button from "@components/Button";
|
||||
import { UserAvatar } from "@components/ui/UserAvatar";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { useIsSm, useIsXs } from "@utils/responsive";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { XIcon } from "lucide-react";
|
||||
@@ -20,6 +21,7 @@ import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import UsersProvider from "@/contexts/UsersProvider";
|
||||
import Navigation from "@/layouts/Navigation";
|
||||
import { OnboardingProvider } from "@/modules/onboarding/OnboardingProvider";
|
||||
import Header, { headerHeight } from "./Header";
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -33,6 +35,7 @@ export default function DashboardLayout({
|
||||
<AnnouncementProvider>
|
||||
<GroupsProvider>
|
||||
<CountryProvider>
|
||||
{!isNetBirdHosted() && <OnboardingProvider />}
|
||||
<DashboardPageContent>{children}</DashboardPageContent>
|
||||
</CountryProvider>
|
||||
</GroupsProvider>
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Power,
|
||||
Share2,
|
||||
Shield,
|
||||
SquareTerminalIcon,
|
||||
Text,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
@@ -50,6 +51,9 @@ import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import { useAccessControl } from "@/modules/access-control/useAccessControl";
|
||||
import { PostureCheckTab } from "@/modules/posture-checks/ui/PostureCheckTab";
|
||||
import { PostureCheckTabTrigger } from "@/modules/posture-checks/ui/PostureCheckTabTrigger";
|
||||
import { SSHAccessType } from "@/modules/access-control/ssh/SSHAccessType";
|
||||
import { SSHAuthorizedGroups } from "@/modules/access-control/ssh/SSHAuthorizedGroups";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -119,6 +123,7 @@ type ModalProps = {
|
||||
initialProtocol?: Protocol;
|
||||
initialPorts?: number[];
|
||||
initialDestinationResource?: PolicyRuleResource;
|
||||
initialTab?: string;
|
||||
};
|
||||
|
||||
export function AccessControlModalContent({
|
||||
@@ -134,8 +139,10 @@ export function AccessControlModalContent({
|
||||
initialProtocol,
|
||||
initialPorts,
|
||||
initialDestinationResource,
|
||||
initialTab,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { permission } = usePermissions();
|
||||
const { users } = useUsers();
|
||||
|
||||
const {
|
||||
portDisabled,
|
||||
@@ -169,6 +176,10 @@ export function AccessControlModalContent({
|
||||
portRanges,
|
||||
setPortRanges,
|
||||
hasPortSupport,
|
||||
sshAccessType,
|
||||
setSshAccessType,
|
||||
sshAuthorizedGroups,
|
||||
setSshAuthorizedGroups,
|
||||
} = useAccessControl({
|
||||
policy,
|
||||
postureCheckTemplates,
|
||||
@@ -182,6 +193,7 @@ export function AccessControlModalContent({
|
||||
});
|
||||
|
||||
const [tab, setTab] = useState(() => {
|
||||
if (initialTab && initialTab !== "") return initialTab;
|
||||
if (!cell) return "policy";
|
||||
if (cell == "posture_checks") return "posture_checks";
|
||||
return "policy";
|
||||
@@ -248,10 +260,10 @@ export function AccessControlModalContent({
|
||||
<TabsContent value={"policy"} className={"pb-8"}>
|
||||
<div className={"px-8 flex-col flex gap-6"}>
|
||||
<div
|
||||
className={"flex justify-between items-center"}
|
||||
className={"flex justify-between items-center gap-10"}
|
||||
data-cy={"protocol-wrapper"}
|
||||
>
|
||||
<div>
|
||||
<div className={"w-full"}>
|
||||
<Label>Protocol</Label>
|
||||
<HelpText className={"max-w-sm"}>
|
||||
Allow only specified network protocols. To change traffic
|
||||
@@ -267,7 +279,7 @@ export function AccessControlModalContent({
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<div
|
||||
className={"flex items-center gap-3"}
|
||||
data-cy={"protocol-select-button"}
|
||||
@@ -281,6 +293,7 @@ export function AccessControlModalContent({
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="udp">UDP</SelectItem>
|
||||
<SelectItem value="icmp">ICMP</SelectItem>
|
||||
<SelectItem value="netbird-ssh">NetBird SSH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -295,14 +308,15 @@ export function AccessControlModalContent({
|
||||
dataCy={"source-group-selector"}
|
||||
popoverWidth={500}
|
||||
placeholder={"Select source(s)..."}
|
||||
showRoutes={true}
|
||||
showRoutes={protocol !== "netbird-ssh"}
|
||||
showResources={false}
|
||||
showPeers={true}
|
||||
showPeers={protocol !== "netbird-ssh"}
|
||||
showResourceCounter={false}
|
||||
showPeerCount={allowEditPeers}
|
||||
disableInlineRemoveGroup={false}
|
||||
values={sourceGroups}
|
||||
onChange={setSourceGroups}
|
||||
users={protocol === "netbird-ssh" ? users : undefined}
|
||||
resource={sourceResource}
|
||||
onResourceChange={setSourceResource}
|
||||
saveGroupAssignments={useSave}
|
||||
@@ -315,6 +329,7 @@ export function AccessControlModalContent({
|
||||
value={direction}
|
||||
onChange={setDirection}
|
||||
disabled={destinationOnlyResources}
|
||||
protocol={protocol}
|
||||
destinationResource={destinationResource}
|
||||
/>
|
||||
|
||||
@@ -328,7 +343,7 @@ export function AccessControlModalContent({
|
||||
popoverWidth={500}
|
||||
placeholder={"Select destination(s)..."}
|
||||
showRoutes={true}
|
||||
showResources={true}
|
||||
showResources={protocol !== "netbird-ssh"}
|
||||
showPeers={true}
|
||||
showResourceCounter={true}
|
||||
showPeerCount={allowEditPeers}
|
||||
@@ -363,33 +378,79 @@ export function AccessControlModalContent({
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2",
|
||||
portDisabled && "opacity-30 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{protocol === "netbird-ssh" ? (
|
||||
<div>
|
||||
<Label className={"flex items-center gap-2"}>
|
||||
<Shield size={14} />
|
||||
Ports
|
||||
</Label>
|
||||
<HelpText>
|
||||
Allow network traffic and access only to specified ports.
|
||||
Select ports or port ranges between 1 and 65535.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={""}>
|
||||
<PortSelector
|
||||
showAll={true}
|
||||
ports={ports}
|
||||
onPortsChange={setPorts}
|
||||
portRanges={portRanges}
|
||||
onPortRangesChange={setPortRanges}
|
||||
disabled={portDisabled}
|
||||
{destinationHasResources && (
|
||||
<Callout
|
||||
variant={"warning"}
|
||||
icon={
|
||||
<AlertCircleIcon
|
||||
size={14}
|
||||
className={"shrink-0 relative top-[3px] text-netbird"}
|
||||
/>
|
||||
}
|
||||
className="mb-6"
|
||||
>
|
||||
SSH access only works on peers, not on routed resources.
|
||||
Please ensure your destination groups contain peers for SSH
|
||||
connectivity.
|
||||
</Callout>
|
||||
)}
|
||||
<div
|
||||
className={"flex justify-between items-center gap-10 mt-2"}
|
||||
>
|
||||
<div className={"w-full"}>
|
||||
<Label className={"flex items-center gap-2"}>
|
||||
<SquareTerminalIcon size={15} />
|
||||
SSH Access
|
||||
</Label>
|
||||
<HelpText>
|
||||
Select {`'Full Access'`} to allow SSH as any local user,
|
||||
or {`'Limited Access'`} to specify which local users each
|
||||
group is allowed to use.
|
||||
</HelpText>
|
||||
</div>
|
||||
<SSHAccessType
|
||||
value={sshAccessType}
|
||||
onChange={setSshAccessType}
|
||||
/>
|
||||
</div>
|
||||
<SSHAuthorizedGroups
|
||||
sourceGroups={sourceGroups}
|
||||
authorizedGroups={sshAuthorizedGroups}
|
||||
setAuthorizedGroups={setSshAuthorizedGroups}
|
||||
accessType={sshAccessType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 mt-2",
|
||||
portDisabled && "opacity-30 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Label className={"flex items-center gap-2"}>
|
||||
<Shield size={14} />
|
||||
Ports
|
||||
</Label>
|
||||
<HelpText>
|
||||
Allow network traffic and access only to specified ports.
|
||||
Select ports or port ranges between 1 and 65535.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={""}>
|
||||
<PortSelector
|
||||
showAll={true}
|
||||
ports={ports}
|
||||
onPortsChange={setPorts}
|
||||
portRanges={portRanges}
|
||||
onPortRangesChange={setPortRanges}
|
||||
disabled={portDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={enabled}
|
||||
|
||||
50
src/modules/access-control/ssh/SSHAccessType.tsx
Normal file
50
src/modules/access-control/ssh/SSHAccessType.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { ShieldHalfIcon, ShieldUserIcon } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
value: "full" | "limited";
|
||||
onChange: Dispatch<SetStateAction<"full" | "limited">>;
|
||||
};
|
||||
|
||||
export const SSHAccessType = ({ value, onChange }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(v) => onChange(v as "full" | "limited")}
|
||||
disabled={!permission?.policies?.update || !permission?.policies?.create}
|
||||
>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<div
|
||||
className={"flex items-center gap-3"}
|
||||
data-cy={"protocol-select-button"}
|
||||
>
|
||||
{value === "full" ? (
|
||||
<ShieldUserIcon size={15} className={"text-nb-gray-300 shrink-0"} />
|
||||
) : (
|
||||
<ShieldHalfIcon size={15} className={"text-nb-gray-300 shrink-0"} />
|
||||
)}
|
||||
<SelectValue placeholder="Select ssh access type..." />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent data-cy={"ssh-access-selection"}>
|
||||
<SelectItem value="full" className={"whitespace-nowrap"}>
|
||||
Full Access
|
||||
</SelectItem>
|
||||
<SelectItem value="limited" className={"whitespace-nowrap"}>
|
||||
Limited Access
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
139
src/modules/access-control/ssh/SSHAuthorizedGroups.tsx
Normal file
139
src/modules/access-control/ssh/SSHAuthorizedGroups.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { AuthorizedGroups } from "@/interfaces/Policy";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { SSHUsernameSelector } from "@/modules/access-control/ssh/SSHUsernameSelector";
|
||||
|
||||
type Props = {
|
||||
sourceGroups?: Group[];
|
||||
accessType?: "full" | "limited";
|
||||
authorizedGroups?: AuthorizedGroups;
|
||||
setAuthorizedGroups?: (authorizedGroups: AuthorizedGroups) => void;
|
||||
};
|
||||
|
||||
export function SSHAuthorizedGroups({
|
||||
sourceGroups,
|
||||
authorizedGroups,
|
||||
setAuthorizedGroups,
|
||||
accessType,
|
||||
}: Props) {
|
||||
const isEmpty =
|
||||
!authorizedGroups || Object.keys(authorizedGroups).length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceGroups) {
|
||||
let groupsMap: AuthorizedGroups = {};
|
||||
sourceGroups.forEach((sourceGroup) => {
|
||||
if (!sourceGroup?.name) return;
|
||||
|
||||
const groupId = sourceGroup?.id;
|
||||
if (groupId) {
|
||||
groupsMap[sourceGroup.name] = authorizedGroups?.[groupId] || [];
|
||||
} else {
|
||||
groupsMap[sourceGroup.name] = [];
|
||||
}
|
||||
});
|
||||
setAuthorizedGroups?.(groupsMap);
|
||||
}
|
||||
}, [sourceGroups]);
|
||||
|
||||
const handleUserNamesChange = useCallback(
|
||||
(groupName: string, values: string[]) => {
|
||||
setAuthorizedGroups?.({
|
||||
...authorizedGroups,
|
||||
[groupName]: values || [],
|
||||
});
|
||||
},
|
||||
[authorizedGroups, setAuthorizedGroups],
|
||||
);
|
||||
|
||||
if (accessType === "full") return;
|
||||
|
||||
if ((accessType === "limited" && isEmpty) || !authorizedGroups) {
|
||||
return (
|
||||
<Callout
|
||||
variant={"info"}
|
||||
icon={<InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />}
|
||||
className="mt-3 py-[.75rem]"
|
||||
>
|
||||
You have not added any source groups yet, please add source groups in
|
||||
order to specify which user group has access to which system users on
|
||||
the destination machines.
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md overflow-hidden mt-3 py-2",
|
||||
"border border-nb-gray-900 bg-nb-gray-920/30",
|
||||
)}
|
||||
>
|
||||
{Object.entries(authorizedGroups).map(([groupName, usernames]) => (
|
||||
<AuthorizedUserRow
|
||||
key={groupName}
|
||||
groupName={groupName}
|
||||
usernames={usernames}
|
||||
sourceGroups={sourceGroups}
|
||||
handleUserNamesChange={(values) =>
|
||||
handleUserNamesChange(groupName, values)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RowProps = {
|
||||
sourceGroups?: Group[];
|
||||
groupName: string;
|
||||
usernames: string[];
|
||||
handleUserNamesChange: (usernames: string[]) => void;
|
||||
};
|
||||
|
||||
function AuthorizedUserRow({
|
||||
sourceGroups,
|
||||
usernames,
|
||||
groupName,
|
||||
handleUserNamesChange,
|
||||
}: RowProps) {
|
||||
const { users } = useUsers();
|
||||
|
||||
const group = useMemo(
|
||||
() => sourceGroups?.find((g) => g.name === groupName),
|
||||
[sourceGroups, groupName],
|
||||
);
|
||||
|
||||
const usersOfGroup = useMemo(
|
||||
() =>
|
||||
users?.filter((user) => user.auto_groups.includes(group?.id || "")) || [],
|
||||
[users, group],
|
||||
);
|
||||
|
||||
return (
|
||||
group && (
|
||||
<div className="flex gap-6 w-full items-center py-2 px-4">
|
||||
<div className={"flex items-center gap-2 col-span-3"}>
|
||||
<GroupBadge group={group} showNewBadge={true} />
|
||||
<HorizontalUsersStack users={usersOfGroup} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-4 min-w-[340px] max-w-[340px] ml-auto"
|
||||
}
|
||||
>
|
||||
<SSHUsernameSelector
|
||||
onChange={handleUserNamesChange}
|
||||
values={usernames}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
261
src/modules/access-control/ssh/SSHUsernameSelector.tsx
Normal file
261
src/modules/access-control/ssh/SSHUsernameSelector.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
|
||||
import { trim } from "lodash";
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
CircleUserIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
|
||||
interface MultiSelectProps {
|
||||
values?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
popoverWidth?: "auto" | number;
|
||||
}
|
||||
|
||||
export function SSHUsernameSelector({
|
||||
values,
|
||||
onChange,
|
||||
disabled = false,
|
||||
popoverWidth = "auto",
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const toggle = (value: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
const previous = values || [];
|
||||
if (previous.includes(value)) {
|
||||
onChange(previous.filter((item) => item !== value));
|
||||
} else {
|
||||
onChange([...previous, value]);
|
||||
}
|
||||
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const notFound = useMemo(() => {
|
||||
const isSearching = search.length > 0;
|
||||
const trimmed = trim(search);
|
||||
return trimmed && !values?.includes(trimmed) && isSearching;
|
||||
}, [search, values]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative items-center",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-1.5 px-2.5",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
)}
|
||||
data-cy={"ssh-username-selector"}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{values?.length === 0 && (
|
||||
<Badge variant={"gray"} className={"font-normal py-1"}>
|
||||
<CircleUserIcon size={12} className={"shrink-0"} />
|
||||
All Local Users
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{values?.map((user) => (
|
||||
<Badge
|
||||
key={user}
|
||||
variant={"gray"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(user);
|
||||
}}
|
||||
className={"font-normal py-1"}
|
||||
>
|
||||
{user}
|
||||
<XIcon
|
||||
size={12}
|
||||
className={"cursor-pointer group-hover:text-black"}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
align="start"
|
||||
side={"top"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
if (formatValue.includes(formatSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative",
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
data-cy={"ssh-username-input"}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={"E.g., root, ec2-user, ubuntu"}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 h-full flex items-center pl-4"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center"}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"absolute right-0 top-0 h-full flex items-center pr-4"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
values?.length != 0 && "p-2",
|
||||
values?.length != 0 && search && "p-2",
|
||||
)}
|
||||
>
|
||||
{notFound && (
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
<CommandItem
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
toggle(search);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge variant={"gray"} className={"font-normal py-1"}>
|
||||
{search}
|
||||
</Badge>
|
||||
<div
|
||||
className={"text-neutral-500 dark:text-nb-gray-300"}
|
||||
>
|
||||
Add username by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
{values?.map((user) => {
|
||||
const isSelected = values?.includes(user);
|
||||
return (
|
||||
<CommandItem
|
||||
key={user}
|
||||
value={user.toString()}
|
||||
onSelect={() => {
|
||||
toggle(user);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"font-normal py-1"}
|
||||
>
|
||||
{user}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import MultipleGroups, {
|
||||
TransparentEditIconButton,
|
||||
} from "@components/ui/MultipleGroups";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useMemo } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
|
||||
@@ -8,9 +12,13 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
|
||||
export default function AccessControlDestinationsCell({
|
||||
policy,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const canUpdate = permission?.policies?.update;
|
||||
|
||||
const firstRule = useMemo(() => {
|
||||
if (policy.rules.length > 0) return policy.rules[0];
|
||||
return undefined;
|
||||
@@ -23,7 +31,10 @@ export default function AccessControlDestinationsCell({
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.destinations as Group[]} />
|
||||
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
|
||||
<MultipleGroups groups={firstRule.destinations as Group[]} />
|
||||
{canUpdate && <TransparentEditIconButton />}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyRow />
|
||||
);
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/Tooltip";
|
||||
import { orderBy } from "lodash";
|
||||
import React, { useMemo } from "react";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { parsePortsToStrings } from "@/modules/access-control/useAccessControl";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
@@ -23,19 +23,7 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
const hasPortRanges = rule?.port_ranges && rule?.port_ranges?.length > 0;
|
||||
const hasAnyPorts = hasPorts || hasPortRanges;
|
||||
|
||||
const allPorts = useMemo(() => {
|
||||
const ports = rule?.ports ?? [];
|
||||
const portRanges =
|
||||
rule?.port_ranges?.map((r) => {
|
||||
if (r.start === r.end) return `${r.start}`;
|
||||
return `${r.start}-${r.end}`;
|
||||
}) ?? [];
|
||||
return orderBy(
|
||||
[...portRanges, ...ports],
|
||||
[(p) => Number(p.split("-")[0])],
|
||||
["asc"],
|
||||
);
|
||||
}, [rule]);
|
||||
const allPorts = useMemo(() => parsePortsToStrings(rule), [rule]);
|
||||
|
||||
const firstTwoPorts = useMemo(() => {
|
||||
return allPorts?.slice(0, 2) ?? [];
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import MultipleGroups, {
|
||||
TransparentEditIconButton,
|
||||
} from "@components/ui/MultipleGroups";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useMemo } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
|
||||
@@ -8,7 +12,11 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
|
||||
export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
const { permission } = usePermissions();
|
||||
const canUpdate = permission?.policies?.update;
|
||||
|
||||
const firstRule = useMemo(() => {
|
||||
if (policy.rules.length > 0) return policy.rules[0];
|
||||
return undefined;
|
||||
@@ -19,7 +27,13 @@ export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.sources as Group[]} />
|
||||
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
|
||||
<MultipleGroups
|
||||
groups={firstRule.sources as Group[]}
|
||||
showUsers={firstRule.protocol === "netbird-ssh"}
|
||||
/>
|
||||
{canUpdate && <TransparentEditIconButton />}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyRow />
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import { Direction } from "@components/ui/PolicyDirection";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { merge, uniqBy } from "lodash";
|
||||
import { merge, orderBy, uniqBy } from "lodash";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import {
|
||||
AuthorizedGroups,
|
||||
Policy,
|
||||
PolicyRule,
|
||||
PolicyRuleResource,
|
||||
PortRange,
|
||||
Protocol,
|
||||
@@ -146,6 +148,21 @@ export const useAccessControl = ({
|
||||
firstRule?.destinationResource ?? initialDestinationResource,
|
||||
);
|
||||
|
||||
const [sshAccessType, setSshAccessType] = useState<"full" | "limited">(() => {
|
||||
if (protocol === "netbird-ssh") {
|
||||
return firstRule?.authorized_groups !== undefined &&
|
||||
Object.keys(firstRule?.authorized_groups).length > 0
|
||||
? "limited"
|
||||
: "full";
|
||||
} else {
|
||||
return "full";
|
||||
}
|
||||
});
|
||||
|
||||
const [sshAuthorizedGroups, setSshAuthorizedGroups] = useState<
|
||||
AuthorizedGroups | undefined
|
||||
>(firstRule?.authorized_groups);
|
||||
|
||||
const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({});
|
||||
const createPostureChecksWithoutID = async () => {
|
||||
const checks = postureChecks.filter(
|
||||
@@ -188,6 +205,7 @@ export const useAccessControl = ({
|
||||
enabled,
|
||||
ports: newPorts,
|
||||
port_ranges: newPortRanges,
|
||||
authorized_groups: sshAuthorizedGroups,
|
||||
},
|
||||
],
|
||||
} as Policy;
|
||||
@@ -238,10 +256,34 @@ export const useAccessControl = ({
|
||||
destinations = tmp;
|
||||
}
|
||||
|
||||
const [newPorts, newPortRanges] = parseAccessControlPorts(
|
||||
ports,
|
||||
portRanges,
|
||||
);
|
||||
let [newPorts, newPortRanges] = parseAccessControlPorts(ports, portRanges);
|
||||
|
||||
let authorizedGroups: AuthorizedGroups = {};
|
||||
if (protocol === "netbird-ssh") {
|
||||
// Set port 22 for SSH protocol
|
||||
newPorts = ["22"];
|
||||
newPortRanges = [];
|
||||
|
||||
const isEmpty =
|
||||
!sshAuthorizedGroups ||
|
||||
Object.keys(sshAuthorizedGroups).length === 0 ||
|
||||
sshAccessType === "full";
|
||||
|
||||
if (!isEmpty) {
|
||||
Object.entries(sshAuthorizedGroups).reduce(
|
||||
(acc, [groupName, usernames]) => {
|
||||
const group = groups?.find((group) => group.name === groupName);
|
||||
if (group?.id) {
|
||||
authorizedGroups[group.id] = usernames;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as AuthorizedGroups,
|
||||
);
|
||||
} else {
|
||||
authorizedGroups = {};
|
||||
}
|
||||
}
|
||||
|
||||
const policyObj = {
|
||||
name,
|
||||
@@ -264,6 +306,8 @@ export const useAccessControl = ({
|
||||
destinationResource: destinationResource || undefined,
|
||||
ports: newPorts,
|
||||
port_ranges: newPortRanges,
|
||||
authorized_groups:
|
||||
protocol === "netbird-ssh" ? authorizedGroups : undefined,
|
||||
},
|
||||
],
|
||||
} as Policy;
|
||||
@@ -374,6 +418,10 @@ export const useAccessControl = ({
|
||||
destinationHasResources,
|
||||
destinationOnlyResources,
|
||||
hasPortSupport,
|
||||
sshAccessType,
|
||||
setSshAccessType,
|
||||
sshAuthorizedGroups,
|
||||
setSshAuthorizedGroups,
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -392,3 +440,18 @@ const parseAccessControlPorts = (ports: number[], portRanges: PortRange[]) => {
|
||||
const allRanges = [...portRanges, ...portRangesFromPorts];
|
||||
return [undefined, allRanges];
|
||||
};
|
||||
|
||||
export const parsePortsToStrings = (rule?: PolicyRule): string[] => {
|
||||
if (!rule) return [];
|
||||
const ports = rule?.ports ?? [];
|
||||
const portRanges =
|
||||
rule?.port_ranges?.map((r) => {
|
||||
if (r.start === r.end) return `${r.start}`;
|
||||
return `${r.start}-${r.end}`;
|
||||
}) ?? [];
|
||||
return orderBy(
|
||||
[...portRanges, ...ports],
|
||||
[(p) => Number(p.split("-")[0])],
|
||||
["asc"],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -203,6 +203,14 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.create")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
<Value>{event.meta.username}</Value> <Value>{event.meta.email}</Value>{" "}
|
||||
was created by <Value>{event?.initiator_name || "NetBird"}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.group.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
@@ -699,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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
172
src/modules/control-center/nodes/SelectUserNode.tsx
Normal file
172
src/modules/control-center/nodes/SelectUserNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import MultipleGroups, {
|
||||
TransparentEditIconButton,
|
||||
} from "@components/ui/MultipleGroups";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
|
||||
type Props = {
|
||||
ns: NameserverGroup;
|
||||
};
|
||||
|
||||
export default function NameserverDistributionGroupsCell({ ns }: Props) {
|
||||
const { groups } = useGroups();
|
||||
const { permission } = usePermissions();
|
||||
const canUpdate = permission?.nameservers?.update;
|
||||
|
||||
const allGroups = ns.groups
|
||||
.map((group) => {
|
||||
@@ -16,5 +23,10 @@ export default function NameserverDistributionGroupsCell({ ns }: Props) {
|
||||
})
|
||||
.filter((g) => g != undefined) as Group[];
|
||||
|
||||
return <MultipleGroups groups={allGroups} />;
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
|
||||
<MultipleGroups groups={allGroups} />
|
||||
{canUpdate && <TransparentEditIconButton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function NameserverGroupTable({
|
||||
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
|
||||
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
|
||||
tableClassName={isGroupPage ? "mt-0" : undefined}
|
||||
inset={!isGroupPage}
|
||||
inset={false}
|
||||
minimal={isGroupPage}
|
||||
showSearchAndFilters={isGroupPage}
|
||||
keepStateInLocalStorage={!isGroupPage}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import React, { lazy } from "react";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
@@ -8,17 +7,21 @@ const NameserverGroupTable = lazy(
|
||||
() => import("@/modules/dns-nameservers/table/NameserverGroupTable"),
|
||||
);
|
||||
|
||||
type Props = {
|
||||
nameserverGroups?: NameserverGroup[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupNameserversSection = ({
|
||||
nameserverGroups,
|
||||
}: {
|
||||
nameserverGroups?: NameserverGroup[];
|
||||
}) => {
|
||||
isLoading = true,
|
||||
}: Props) => {
|
||||
const { group } = useGroupContext();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<NameserverGroupTable
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
nameserverGroups={nameserverGroups}
|
||||
isGroupPage={true}
|
||||
distributionGroups={[group]}
|
||||
|
||||
@@ -1,68 +1,19 @@
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import React from "react";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
import PeerRouteNameCell from "@/modules/peer/PeerRouteNameCell";
|
||||
import GroupedRouteNetworkRangeCell from "@/modules/route-group/GroupedRouteNetworkRangeCell";
|
||||
import NetworkRoutesTable from "@/modules/route-group/NetworkRoutesTable";
|
||||
import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
|
||||
import RouteActiveCell from "@/modules/routes/RouteActiveCell";
|
||||
import RouteMetricCell from "@/modules/routes/RouteMetricCell";
|
||||
|
||||
export const GroupNetworkRoutesTableColumns: ColumnDef<Route>[] = [
|
||||
{
|
||||
accessorKey: "network_id",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Name</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <PeerRouteNameCell route={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
accessorKey: "domain_search",
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
accessorKey: "network",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Network</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupedRouteNetworkRangeCell
|
||||
domains={row.original?.domains}
|
||||
network={row.original?.network}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "metric",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Metric</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <RouteMetricCell metric={row.original.metric} />,
|
||||
sortingFn: "alphanumeric",
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
sortingFn: "basic",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Active</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <RouteActiveCell route={row.original} />,
|
||||
},
|
||||
];
|
||||
type Props = {
|
||||
routes?: Route[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupNetworkRoutesSection = ({ routes }: { routes?: Route[] }) => {
|
||||
export const GroupNetworkRoutesSection = ({
|
||||
routes,
|
||||
isLoading = true,
|
||||
}: Props) => {
|
||||
const groupedRoutes = useGroupedRoutes({ routes });
|
||||
const { group } = useGroupContext();
|
||||
|
||||
@@ -70,7 +21,7 @@ export const GroupNetworkRoutesSection = ({ routes }: { routes?: Route[] }) => {
|
||||
<GroupDetailsTableContainer>
|
||||
<NetworkRoutesTable
|
||||
isGroupPage={true}
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
groupedRoutes={groupedRoutes}
|
||||
routes={routes}
|
||||
distributionGroups={[group]}
|
||||
|
||||
@@ -103,7 +103,12 @@ const GroupPeersTableColumns: ColumnDef<Peer>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const GroupPeersSection = ({ peers }: { peers?: Peer[] }) => {
|
||||
type Props = {
|
||||
peers?: Peer[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupPeersSection = ({ peers, isLoading = true }: Props) => {
|
||||
const { group, addPeersToGroup, removePeersFromGroup } = useGroupContext();
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -112,7 +117,7 @@ export const GroupPeersSection = ({ peers }: { peers?: Peer[] }) => {
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<GroupPeersTable
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
peers={peers}
|
||||
columns={GroupPeersTableColumns}
|
||||
selectedRows={selectedRows}
|
||||
|
||||
@@ -6,13 +6,17 @@ import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetail
|
||||
const AccessControlTable = lazy(
|
||||
() => import("@/modules/access-control/table/AccessControlTable"),
|
||||
);
|
||||
type Props = {
|
||||
policies?: Policy[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupPoliciesSection = ({ policies }: { policies?: Policy[] }) => {
|
||||
export const GroupPoliciesSection = ({ policies, isLoading = true }: Props) => {
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<PoliciesProvider>
|
||||
<AccessControlTable
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
policies={policies}
|
||||
isGroupPage={true}
|
||||
/>
|
||||
|
||||
@@ -100,11 +100,15 @@ const GroupResourcesColumns: ColumnDef<NetworkResourceWithNetwork>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
resources?: NetworkResourceWithNetwork[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupResourcesSection = ({
|
||||
resources,
|
||||
}: {
|
||||
resources?: NetworkResourceWithNetwork[];
|
||||
}) => {
|
||||
isLoading = true,
|
||||
}: Props) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const { permission } = usePermissions();
|
||||
const router = useRouter();
|
||||
@@ -118,6 +122,7 @@ export const GroupResourcesSection = ({
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
isLoading={isLoading}
|
||||
showSearchAndFilters={true}
|
||||
renderRow={(row, children) => (
|
||||
<NetworkProvider
|
||||
|
||||
@@ -7,17 +7,21 @@ const SetupKeysTable = lazy(
|
||||
() => import("@/modules/setup-keys/SetupKeysTable"),
|
||||
);
|
||||
|
||||
type Props = {
|
||||
setupKeys?: SetupKey[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupSetupKeysSection = ({
|
||||
setupKeys,
|
||||
}: {
|
||||
setupKeys?: SetupKey[];
|
||||
}) => {
|
||||
isLoading = true,
|
||||
}: Props) => {
|
||||
const { group } = useGroupContext();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<SetupKeysTable
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
setupKeys={setupKeys}
|
||||
isGroupPage={true}
|
||||
groups={[group]}
|
||||
|
||||
@@ -113,7 +113,12 @@ export const GroupUsersTableColumns: ColumnDef<User>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const GroupUsersSection = ({ users }: { users?: User[] }) => {
|
||||
type Props = {
|
||||
users?: User[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GroupUsersSection = ({ users, isLoading = true }: Props) => {
|
||||
const { group, addUsersToGroup, removeUsersFromGroup } = useGroupContext();
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -122,7 +127,7 @@ export const GroupUsersSection = ({ users }: { users?: User[] }) => {
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<UsersTable
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
columns={GroupUsersTableColumns}
|
||||
selectedRows={selectedRows}
|
||||
setSelectedRows={setSelectedRows}
|
||||
|
||||
@@ -121,9 +121,10 @@ export default function useGroupDetails(groupId: string) {
|
||||
isSetupKeysLoading ||
|
||||
isUsersLoading ||
|
||||
isPeerLoading ||
|
||||
isLoadingResources;
|
||||
isLoadingResources ||
|
||||
isNetworksLoading;
|
||||
|
||||
return useMemo(() => {
|
||||
const groupDetails = useMemo(() => {
|
||||
if (isLoading || !group) return null;
|
||||
|
||||
return {
|
||||
@@ -147,4 +148,9 @@ export default function useGroupDetails(groupId: string) {
|
||||
linkedPeers,
|
||||
linkedNetworkResources,
|
||||
]);
|
||||
|
||||
return {
|
||||
groupDetails,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { Layers3Icon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
@@ -20,6 +19,7 @@ import GroupsActionCell from "@/modules/groups/table/GroupsActionCell";
|
||||
import GroupsCountCell from "@/modules/groups/table/GroupsCountCell";
|
||||
import GroupsNameCell from "@/modules/groups/table/GroupsNameCell";
|
||||
import useGroupsUsage, { GroupUsage } from "@/modules/groups/useGroupsUsage";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
|
||||
export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
{
|
||||
|
||||
@@ -6,15 +6,15 @@ type Props = {
|
||||
};
|
||||
|
||||
export const useGroupIdentification = ({ id, issued }: Props) => {
|
||||
const isJWTGroup = issued === GroupIssued.JWT;
|
||||
const isOktaGroup = !!id?.includes("okta");
|
||||
const isGoogleGroup = !!id?.includes("google");
|
||||
const isAzureGroup = !!id?.includes("azure");
|
||||
const isJumpcloudGroup = !!id?.includes("jumpcloud");
|
||||
const isOIDCGroup = !!id?.includes("oidc");
|
||||
|
||||
const isRegularGroup =
|
||||
!isJWTGroup && !isOktaGroup && !isGoogleGroup && !isAzureGroup;
|
||||
|
||||
const isIntegrationGroup = isOktaGroup || isGoogleGroup || isAzureGroup;
|
||||
const isJWTGroup = issued === GroupIssued.JWT;
|
||||
const isIntegrationGroup = issued === GroupIssued.INTEGRATION;
|
||||
const isRegularGroup = issued === GroupIssued.API || isJWTGroup;
|
||||
|
||||
return {
|
||||
isOktaGroup,
|
||||
@@ -22,6 +22,8 @@ export const useGroupIdentification = ({ id, issued }: Props) => {
|
||||
isAzureGroup,
|
||||
isJWTGroup,
|
||||
isRegularGroup,
|
||||
isJumpcloudGroup,
|
||||
isOIDCGroup,
|
||||
isIntegrationGroup,
|
||||
};
|
||||
};
|
||||
|
||||
294
src/modules/instance-setup/InstanceSetupWizard.tsx
Normal file
294
src/modules/instance-setup/InstanceSetupWizard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user