Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aec338c43 | ||
|
|
f4f0c240fd | ||
|
|
04e22a3c7e | ||
|
|
54ef076303 | ||
|
|
92676b6c38 | ||
|
|
3affa8908f | ||
|
|
52fd984912 | ||
|
|
83e3159ee4 |
@@ -15,4 +15,4 @@
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
|
||||
"wasmPath": "$NETBIRD_WASM_PATH"
|
||||
}
|
||||
}
|
||||
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -56,6 +56,7 @@
|
||||
"flowbite": "^1.8.1",
|
||||
"flowbite-react": "^0.6.4",
|
||||
"framer-motion": "^10.16.4",
|
||||
"ip-address": "^10.1.0",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -6598,15 +6599,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-7.1.0.tgz",
|
||||
"integrity": "sha512-V9pWC/VJf2lsXqP7IWJ+pe3P1/HCYGBMZrrnT62niLGjAfCbeiwXMUxaeHvnVlz19O27pvXP4azs+Pj/A0x+SQ==",
|
||||
"dependencies": {
|
||||
"jsbn": "1.1.0",
|
||||
"sprintf-js": "1.1.2"
|
||||
},
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-cidr": {
|
||||
@@ -6621,6 +6619,19 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-cidr/node_modules/ip-address": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-7.1.0.tgz",
|
||||
"integrity": "sha512-V9pWC/VJf2lsXqP7IWJ+pe3P1/HCYGBMZrrnT62niLGjAfCbeiwXMUxaeHvnVlz19O27pvXP4azs+Pj/A0x+SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsbn": "1.1.0",
|
||||
"sprintf-js": "1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -8561,7 +8572,8 @@
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
|
||||
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
|
||||
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"flowbite": "^1.8.1",
|
||||
"flowbite-react": "^0.6.4",
|
||||
"framer-motion": "^10.16.4",
|
||||
"ip-address": "^10.1.0",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -7,7 +7,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon, ServerIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -15,7 +15,7 @@ import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const NameserverGroupTable = lazy(
|
||||
() => import("@/modules/dns-nameservers/table/NameserverGroupTable"),
|
||||
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
|
||||
);
|
||||
|
||||
export default function NameServers() {
|
||||
@@ -40,7 +40,7 @@ export default function NameServers() {
|
||||
href={"/dns/nameservers"}
|
||||
label={"Nameservers"}
|
||||
active
|
||||
icon={<ServerIcon size={13} />}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Nameservers</h1>
|
||||
|
||||
8
src/app/(dashboard)/dns/zones/layout.tsx
Normal file
8
src/app/(dashboard)/dns/zones/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: `Zones - DNS - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
70
src/app/(dashboard)/dns/zones/page.tsx
Normal file
70
src/app/(dashboard)/dns/zones/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
|
||||
const DNSZonesTable = lazy(
|
||||
() => import("@/modules/dns/zones/table/DNSZonesTable"),
|
||||
);
|
||||
|
||||
export default function DNSZonePage() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: zones, isLoading } = useFetchApi<DNSZone[]>("/dns/zones");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item label={"DNS"} icon={<DNSIcon size={13} />} />
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/zones"}
|
||||
label={"Zones"}
|
||||
active
|
||||
icon={<DNSZoneIcon size={16} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Zones</h1>
|
||||
<Paragraph>
|
||||
Manage DNS zones to control domain name resolution for your network.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
|
||||
DNS Zones
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess page={"DNS Zones"} hasAccess={permission?.dns?.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<DNSZonesProvider>
|
||||
<DNSZonesTable
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
data={zones}
|
||||
/>
|
||||
</DNSZonesProvider>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
@@ -24,6 +25,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { GroupDNSZonesSection } from "@/modules/groups/details/GroupDNSZonesSection";
|
||||
import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection";
|
||||
import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection";
|
||||
import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection";
|
||||
@@ -134,7 +136,9 @@ const validAllGroupTabs = [
|
||||
"resources",
|
||||
"network-routes",
|
||||
"nameservers",
|
||||
"zones",
|
||||
];
|
||||
|
||||
const validOtherGroupTabs = ["users", "peers", "setup-keys"];
|
||||
|
||||
const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
@@ -162,6 +166,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
const resourcesCount = groupDetails?.resources_count || 0;
|
||||
const routesCount = groupDetails?.routes?.length || 0;
|
||||
const nameserversCount = groupDetails?.nameservers?.length || 0;
|
||||
const zonesCount = groupDetails?.zones?.length || 0;
|
||||
const setupKeysCount = groupDetails?.setupKeys?.length || 0;
|
||||
|
||||
return (
|
||||
@@ -249,6 +254,19 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
{singularize("Nameservers", nameserversCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value={"zones"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<DNSZoneIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Zones", zonesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
{group.name !== "All" && (
|
||||
<TabsTrigger
|
||||
value={"setup-keys"}
|
||||
@@ -304,6 +322,13 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"zones"} className={"pb-8"}>
|
||||
<GroupDNSZonesSection
|
||||
zones={groupDetails?.zones}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"setup-keys"} className={"pb-8"}>
|
||||
<GroupSetupKeysSection
|
||||
setupKeys={groupDetails?.setupKeys}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
TimerResetIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toASCII } from "punycode";
|
||||
@@ -61,12 +59,12 @@ import type { Peer } from "@/interfaces/Peer";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
|
||||
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
|
||||
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
|
||||
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";
|
||||
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
|
||||
|
||||
export default function PeerPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -80,12 +78,6 @@ export default function PeerPage() {
|
||||
|
||||
useRedirect("/peers", false, !peerId || isRestricted);
|
||||
|
||||
const peerKey = useMemo(() => {
|
||||
let id = peer?.id ?? "";
|
||||
let expiration = peer?.login_expiration_enabled ? "1" : "0";
|
||||
return `${id}-${expiration}`;
|
||||
}, [peer]);
|
||||
|
||||
if (isRestricted) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -106,7 +98,7 @@ export default function PeerPage() {
|
||||
|
||||
return peer && !isLoading ? (
|
||||
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
|
||||
<PeerOverview key={peerKey} />
|
||||
<PeerOverview key={peer?.id} />
|
||||
</PeerProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
@@ -142,12 +134,6 @@ const PeerGeneralInformation = () => {
|
||||
const { peer, user, peerGroups, update } = usePeer();
|
||||
const [name, setName] = useState(peer.name);
|
||||
const [showEditNameModal, setShowEditNameModal] = useState(false);
|
||||
const [loginExpiration, setLoginExpiration] = useState(
|
||||
peer.login_expiration_enabled,
|
||||
);
|
||||
const [inactivityExpiration, setInactivityExpiration] = useState(
|
||||
peer.inactivity_expiration_enabled,
|
||||
);
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
initial: peerGroups?.filter((g) => g?.name !== "All"),
|
||||
@@ -159,8 +145,6 @@ const PeerGeneralInformation = () => {
|
||||
*/
|
||||
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
|
||||
const updatePeer = async (newName?: string) => {
|
||||
@@ -170,8 +154,6 @@ const PeerGeneralInformation = () => {
|
||||
if (permission.peers.update) {
|
||||
const updateRequest = update({
|
||||
name: newName ?? name,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
});
|
||||
batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest];
|
||||
} else {
|
||||
@@ -184,11 +166,7 @@ const PeerGeneralInformation = () => {
|
||||
promise: Promise.all(batchCall).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
mutate("/groups");
|
||||
updateHasChangedRef([
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
updateHasChangedRef([selectedGroups]);
|
||||
}),
|
||||
loadingMessage: "Saving the peer...",
|
||||
});
|
||||
@@ -284,41 +262,7 @@ const PeerGeneralInformation = () => {
|
||||
<PeerInformationCard peer={peer} />
|
||||
|
||||
<div className={"flex flex-col gap-6 lg:w-1/2 transition-all"}>
|
||||
<div>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
value={loginExpiration}
|
||||
icon={<TimerResetIcon size={16} />}
|
||||
onChange={(state) => {
|
||||
setLoginExpiration(state);
|
||||
!state && setInactivityExpiration(false);
|
||||
}}
|
||||
/>
|
||||
{permission.peers.update && !!peer?.user_id && (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
|
||||
!loginExpiration
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "bg-nb-gray-930/80",
|
||||
)}
|
||||
>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
variant={"blank"}
|
||||
value={inactivityExpiration}
|
||||
onChange={setInactivityExpiration}
|
||||
title={"Require login after disconnect"}
|
||||
description={
|
||||
"Enable to require authentication after users disconnect from management for 10 minutes."
|
||||
}
|
||||
className={
|
||||
!loginExpiration ? "opacity-40 pointer-events-none" : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PeerExpirationSettings />
|
||||
|
||||
<PeerSSHToggle />
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetbirdSSHProtocolSupported } from "@utils/version";
|
||||
|
||||
export default function RDPPage() {
|
||||
const { peerId } = useRDPQueryParams();
|
||||
@@ -85,11 +84,8 @@ function RDPSession({ peer }: Props) {
|
||||
try {
|
||||
setCredentials(rdpCredentials);
|
||||
setIsNetBirdConnecting(true);
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
await client.connectTemporary(peer.id, [
|
||||
`${protocol}/${rdpCredentials.port}`,
|
||||
`tcp/${rdpCredentials.port}`,
|
||||
]);
|
||||
setIsNetBirdConnecting(false);
|
||||
} catch (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>
|
||||
);
|
||||
}
|
||||
19
src/assets/icons/DNSZoneIcon.tsx
Normal file
19
src/assets/icons/DNSZoneIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function DNSZoneIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5 5a2 2 0 0 0-2 2v3a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V7a2 2 0 0 0-2-2H5Zm9 2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17ZM3 17v-3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm11-2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</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];
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
30
src/assets/icons/SlackIcon.tsx
Normal file
30
src/assets/icons/SlackIcon.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function SlackIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
width="127"
|
||||
height="127"
|
||||
viewBox="0 0 127 127"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
@@ -76,6 +76,7 @@ export const buttonVariants = cva(
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
|
||||
"data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50",
|
||||
],
|
||||
danger: [
|
||||
"", // TODO - add danger button styles for light mode
|
||||
|
||||
@@ -93,25 +93,53 @@ const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "danger";
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
>(({ className, inset, variant = "default", onClick, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
|
||||
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick && onClick(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
inset,
|
||||
variant = "default",
|
||||
onClick,
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
|
||||
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (href) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick && onClick(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{href ? (
|
||||
<a href={href} target={target} rel={rel}>
|
||||
{props.children}
|
||||
</a>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -16,8 +17,9 @@ export interface InputProps
|
||||
icon?: React.ReactNode;
|
||||
error?: string;
|
||||
errorTooltip?: boolean;
|
||||
errorTooltipPosition?: "top" | "top-right";
|
||||
errorTooltipPosition?: "top" | "top-right" | "bottom";
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -104,7 +104,7 @@ const TableRow = React.forwardRef<
|
||||
" transition-colors group/table-row data-[state=selected]:bg-neutral-100 dark:data-[state=selected]:bg-nb-gray-930/70",
|
||||
"dark:data-[state=selected]:border-nb-gray-900",
|
||||
minimal
|
||||
? "dark:hover:bg-nb-gray-900/10"
|
||||
? "dark:hover:bg-nb-gray-910/[15%]"
|
||||
: "border-b dark:border-zinc-700/40 dark:hover:bg-nb-gray-940 hover:bg-neutral-100/50",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
145
src/components/ui/HelpAndSupportButton.tsx
Normal file
145
src/components/ui/HelpAndSupportButton.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BookText,
|
||||
CircleQuestionMark,
|
||||
MailIcon,
|
||||
MessageSquareShare,
|
||||
MessagesSquareIcon,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Button from "@components/Button";
|
||||
import { cn } from "@utils/helpers";
|
||||
import SlackIcon from "@/assets/icons/SlackIcon";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
|
||||
export default function HelpAndSupportButton() {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"default-outline"}
|
||||
className={cn(
|
||||
"!rounded-full h-[38px] w-[38px] !p-0",
|
||||
dropdownOpen && "text-white",
|
||||
)}
|
||||
>
|
||||
<CircleQuestionMark size={18} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1 px-1">
|
||||
<div className="text-sm font-normal leading-none text-nb-gray-200 py-1">
|
||||
Help and Support
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
href="https://docs.netbird.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
asChild
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<BookText size={14} />
|
||||
Documentation
|
||||
</div>
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
href="https://docs.netbird.io/help/troubleshooting-client"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
asChild
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<TriangleAlert size={14} />
|
||||
Troubleshooting
|
||||
</div>
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{isNetBirdHosted() && (
|
||||
<DropdownMenuItem href="mailto:support@netbird.io?subject=Support Request">
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<MailIcon size={14} />
|
||||
support@netbird.io
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
href="https://forum.netbird.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
asChild
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<MessagesSquareIcon size={14} />
|
||||
NetBird Forum
|
||||
</div>
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
href="https://docs.netbird.io/slack-url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
asChild
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<SlackIcon size={14} />
|
||||
NetBird Slack
|
||||
</div>
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
href={"https://forms.gle/TeLw2zrXEdw6RcQ36"}
|
||||
target={"_blank"}
|
||||
rel="noopener noreferrer"
|
||||
asChild
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<MessageSquareShare size={14} />
|
||||
Feedback
|
||||
</div>
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,9 @@ type Props = {
|
||||
className?: string;
|
||||
hasFiltersApplied?: boolean;
|
||||
onResetFilters?: () => void;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export default function NoResults({
|
||||
icon,
|
||||
title = "Could not find any results",
|
||||
@@ -23,6 +25,7 @@ export default function NoResults({
|
||||
className,
|
||||
hasFiltersApplied = false,
|
||||
onResetFilters,
|
||||
contentClassName,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -65,7 +68,9 @@ export default function NoResults({
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("max-w-md mx-auto relative z-20 py-6")}>
|
||||
<div
|
||||
className={cn("max-w-md mx-auto relative z-20 py-6", contentClassName)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"mx-auto w-14 h-14 bg-nb-gray-930 flex items-center justify-center mb-3 rounded-md"
|
||||
|
||||
@@ -19,11 +19,12 @@ export default function PeerCountBadge({
|
||||
className,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const { dropdownOptions } = useGroups();
|
||||
const { dropdownOptions, groups } = useGroups();
|
||||
|
||||
const currentGroup = useMemo(() => {
|
||||
return dropdownOptions?.find((g) => g.name === group?.name);
|
||||
}, [group, dropdownOptions]);
|
||||
const options = dropdownOptions?.find((g) => g.name === group?.name);
|
||||
return options ?? groups?.find((g) => g.name === group?.name);
|
||||
}, [group, dropdownOptions, groups]);
|
||||
|
||||
const peerCount = useMemo(() => {
|
||||
let peerCount = currentGroup?.peers_count ?? 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn, generateColorFromUser } from "@utils/helpers";
|
||||
import { Avatar } from "flowbite-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
|
||||
type Props = {
|
||||
@@ -13,26 +13,27 @@ export const UserAvatar = ({ size = "default" }: Props) => {
|
||||
const [pictureLoaded, setPictureLoaded] = useState(true);
|
||||
|
||||
const getAvatarSize = () => {
|
||||
if (size === "small") return "sm";
|
||||
if (size === "large") return "lg";
|
||||
return "md";
|
||||
if (size === "small") return 32;
|
||||
if (size === "default") return 40;
|
||||
if (size === "large") return 48;
|
||||
return 35.2;
|
||||
};
|
||||
|
||||
return pictureLoaded ? (
|
||||
<Avatar
|
||||
alt=""
|
||||
img={user?.picture}
|
||||
rounded
|
||||
return pictureLoaded && user?.picture ? (
|
||||
<Image
|
||||
src={user?.picture}
|
||||
alt={""}
|
||||
onError={() => setPictureLoaded(false)}
|
||||
size={getAvatarSize()}
|
||||
className={"shrink-0"}
|
||||
width={getAvatarSize()}
|
||||
height={getAvatarSize()}
|
||||
className={"rounded-full"}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full flex items-center justify-center bg-nb-gray-900 text-netbird uppercase",
|
||||
size == "small" && "w-8 h-8",
|
||||
size == "medium" && "w-[2.3rem] h-[2.3rem]",
|
||||
size == "medium" && "w-[2.2rem] h-[2.2rem]",
|
||||
size == "default" && "w-10 h-10",
|
||||
size == "large" && "w-12 h-12",
|
||||
)}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function UserDropdown() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="flex flex-col space-y-0.5 px-1">
|
||||
<div className="text-sm font-medium leading-none dark:text-gray-300">
|
||||
<TextWithTooltip
|
||||
text={user?.name}
|
||||
|
||||
@@ -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.62 Released - Local Users and Simplified IdP Integration",
|
||||
link: "https://netbird.io/knowledge-hub/local-users-simplified-idp",
|
||||
linkText: "Read Release Article",
|
||||
variant: "important", // "default" or "important"
|
||||
isExternal: true,
|
||||
closeable: true,
|
||||
isCloudOnly: false,
|
||||
|
||||
@@ -66,6 +66,8 @@ export default function DialogProvider({ children }: Props) {
|
||||
<ModalContent
|
||||
maxWidthClass={dialogOptions.maxWidthClass || "max-w-[400px]"}
|
||||
showClose={false}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<ModalHeader
|
||||
center={dialogOptions.type == "center"}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +22,7 @@ export interface Account {
|
||||
dns_domain: string;
|
||||
network_range?: string;
|
||||
lazy_connection_enabled: boolean;
|
||||
embedded_idp_enabled?: boolean;
|
||||
auto_update_version: string;
|
||||
};
|
||||
onboarding?: AccountOnboarding;
|
||||
|
||||
25
src/interfaces/DNS.ts
Normal file
25
src/interfaces/DNS.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface DNSZone {
|
||||
id?: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
enabled: boolean;
|
||||
enable_search_domain: boolean;
|
||||
distribution_groups: string[];
|
||||
records?: DNSRecord[];
|
||||
groups_search?: string;
|
||||
}
|
||||
|
||||
export interface DNSRecord {
|
||||
id?: string;
|
||||
name: string;
|
||||
type: "A" | "AAAA" | "CNAME";
|
||||
content: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
export type DNSRecordType = "A" | "AAAA" | "CNAME";
|
||||
|
||||
export const DNS_ZONE_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/dns/custom-zones";
|
||||
export const DNS_RECORDS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/dns/custom-zones#adding-records-to-a-zone";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface Permissions {
|
||||
settings: Permission;
|
||||
accounts: Permission;
|
||||
billing: Permission;
|
||||
identity_providers: Permission;
|
||||
|
||||
edr: Permission;
|
||||
event_streaming: Permission;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,8 +11,9 @@ import React from "react";
|
||||
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import HelpAndSupportButton from "@components/ui/HelpAndSupportButton";
|
||||
|
||||
export const headerHeight = 75;
|
||||
export const headerHeight = 65;
|
||||
|
||||
export default function NavbarWithDropdown() {
|
||||
const router = useRouter();
|
||||
@@ -31,7 +32,7 @@ export default function NavbarWithDropdown() {
|
||||
<AnnouncementBanner />
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray backdrop-blur-lg sm:px-6",
|
||||
"bg-white px-2 py-3 dark:border-gray-700 dark:bg-nb-gray backdrop-blur-lg sm:px-6",
|
||||
"border-b dark:border-zinc-700/40 px-3 md:px-4 w-full",
|
||||
"flex justify-between items-center transition-all",
|
||||
)}
|
||||
@@ -62,7 +63,8 @@ export default function NavbarWithDropdown() {
|
||||
<ToggleCollapsableNavigationButton />
|
||||
</div>
|
||||
|
||||
<div className="flex md:order-2 gap-4 items-center">
|
||||
<div className="flex md:order-2 gap-5 items-center">
|
||||
<HelpAndSupportButton />
|
||||
<UserDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,6 +143,12 @@ export default function Navigation({
|
||||
href={"/dns/nameservers"}
|
||||
visible={permission.nameservers.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Zones"
|
||||
isChild
|
||||
href={"/dns/zones"}
|
||||
visible={permission?.dns?.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="DNS Settings"
|
||||
isChild
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -707,6 +707,31 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Identity Provider
|
||||
*/
|
||||
|
||||
if (event.activity_code == "identityprovider.create")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Identity provider <Value>{m.name}</Value> was created
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "identityprovider.update")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Identity provider <Value>{m.name}</Value> was updated
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "identityprovider.delete")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Identity provider <Value>{m.name}</Value> was deleted
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={"flex gap-2.5 items-center"}>
|
||||
<span className={"mb-[1px]"}>{event.activity}</span>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import GoogleLogo from "@/assets/nameservers/google.svg";
|
||||
import Quad9Logo from "@/assets/nameservers/quad9.svg";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverGroup, NameserverPresets } from "@/interfaces/Nameserver";
|
||||
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
|
||||
import NameserverModal from "@/modules/dns/nameservers/NameserverModal";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -19,14 +19,14 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
|
||||
import NameserverTemplateModal from "@/modules/dns-nameservers/NameserverTemplateModal";
|
||||
import NameserverActionCell from "@/modules/dns-nameservers/table/NameserverActionCell";
|
||||
import NameserverActiveCell from "@/modules/dns-nameservers/table/NameserverActiveCell";
|
||||
import NameserverDistributionGroupsCell from "@/modules/dns-nameservers/table/NameserverDistributionGroupsCell";
|
||||
import NameserverMatchDomainsCell from "@/modules/dns-nameservers/table/NameserverMatchDomainsCell";
|
||||
import NameserverNameCell from "@/modules/dns-nameservers/table/NameserverNameCell";
|
||||
import NameserverNameserversCell from "@/modules/dns-nameservers/table/NameserverNameserversCell";
|
||||
import NameserverModal from "@/modules/dns/nameservers/NameserverModal";
|
||||
import NameserverTemplateModal from "@/modules/dns/nameservers/NameserverTemplateModal";
|
||||
import NameserverActionCell from "@/modules/dns/nameservers/table/NameserverActionCell";
|
||||
import NameserverActiveCell from "@/modules/dns/nameservers/table/NameserverActiveCell";
|
||||
import NameserverDistributionGroupsCell from "@/modules/dns/nameservers/table/NameserverDistributionGroupsCell";
|
||||
import NameserverMatchDomainsCell from "@/modules/dns/nameservers/table/NameserverMatchDomainsCell";
|
||||
import NameserverNameCell from "@/modules/dns/nameservers/table/NameserverNameCell";
|
||||
import NameserverNameserversCell from "@/modules/dns/nameservers/table/NameserverNameserversCell";
|
||||
|
||||
export const NameserverGroupTableColumns: ColumnDef<NameserverGroup>[] = [
|
||||
{
|
||||
359
src/modules/dns/zones/DNSRecordModal.tsx
Normal file
359
src/modules/dns/zones/DNSRecordModal.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalTrigger,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import Separator from "@components/Separator";
|
||||
import { validator } from "@utils/helpers";
|
||||
import { Address4, Address6 } from "ip-address";
|
||||
import { ClockIcon, ExternalLinkIcon, GlobeIcon } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
DNS_RECORDS_DOCS_LINK,
|
||||
DNSRecord,
|
||||
DNSRecordType,
|
||||
DNSZone,
|
||||
} from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
zone: DNSZone;
|
||||
record?: DNSRecord;
|
||||
};
|
||||
|
||||
export default function DNSRecordModal({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
zone,
|
||||
record,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
|
||||
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
|
||||
{open && (
|
||||
<DNSRecordModalContent
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
onSuccessAdded={() => {
|
||||
setTimeout(() => {
|
||||
const row = document.querySelector<HTMLElement>(
|
||||
`[data-row-id="${zone.id}"]`,
|
||||
);
|
||||
if (row?.getAttribute("data-accordion") === "closed") {
|
||||
row?.click();
|
||||
}
|
||||
row?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 200);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
zone={zone}
|
||||
record={record}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalProps = {
|
||||
onSuccess?: () => void;
|
||||
onSuccessAdded?: () => void;
|
||||
zone: DNSZone;
|
||||
record?: DNSRecord;
|
||||
};
|
||||
|
||||
export function DNSRecordModalContent({
|
||||
onSuccess,
|
||||
onSuccessAdded,
|
||||
zone,
|
||||
record,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { addRecord, updateRecord } = useDNSZones();
|
||||
|
||||
const getInitialDomain = () => {
|
||||
if (!record) return "";
|
||||
if (record.name === zone.domain) return "";
|
||||
return record.name.replace(`.${zone.domain}`, "");
|
||||
};
|
||||
|
||||
const [domain, setDomain] = useState(record?.name ? getInitialDomain() : "");
|
||||
const [ttl, setTtl] = useState(record ? record.ttl.toString() : "300");
|
||||
const [type, setType] = useState<DNSRecordType>(record?.type ?? "A");
|
||||
const [recordValue, setRecordValue] = useState(record?.content ?? "");
|
||||
|
||||
const domainError = useMemo(() => {
|
||||
if (domain == "") return "";
|
||||
const valid = validator.isValidDomain(domain, {
|
||||
allowWildcard: false,
|
||||
allowOnlyTld: true,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. example.com or intra.example.com";
|
||||
}
|
||||
}, [domain]);
|
||||
|
||||
const ipv4Error = useMemo(() => {
|
||||
if (recordValue === "" || type !== "A") return "";
|
||||
const valid = Address4.isValid(recordValue);
|
||||
if (!valid) {
|
||||
return "Please enter a valid IPv4 address, e.g. 192.168.1.1";
|
||||
}
|
||||
}, [recordValue, type]);
|
||||
|
||||
const ipv6Error = useMemo(() => {
|
||||
if (recordValue === "" || type !== "AAAA") return "";
|
||||
const valid = Address6.isValid(recordValue);
|
||||
if (!valid) {
|
||||
return "Please enter a valid IPv6 address, e.g. 2001:0db8:85a3::8a2e:0370:7334";
|
||||
}
|
||||
}, [recordValue, type]);
|
||||
|
||||
const cnameError = useMemo(() => {
|
||||
if (recordValue === "" || type !== "CNAME") return "";
|
||||
const valid = validator.isValidDomain(recordValue, {
|
||||
allowWildcard: false,
|
||||
allowOnlyTld: false,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. example.com or server.example.com";
|
||||
}
|
||||
}, [recordValue, type]);
|
||||
|
||||
const handleAddRecord = async () => {
|
||||
const name = domain !== "" ? `${domain}.${zone.domain}` : zone.domain;
|
||||
|
||||
if (record) {
|
||||
updateRecord(zone, {
|
||||
id: record.id,
|
||||
name,
|
||||
type,
|
||||
content: recordValue,
|
||||
ttl: parseInt(ttl),
|
||||
}).then(onSuccess);
|
||||
} else {
|
||||
addRecord(zone, {
|
||||
name,
|
||||
type,
|
||||
content: recordValue,
|
||||
ttl: parseInt(ttl),
|
||||
}).then(onSuccessAdded);
|
||||
}
|
||||
};
|
||||
|
||||
const canUpdateOrCreate =
|
||||
!cnameError &&
|
||||
!ipv6Error &&
|
||||
!ipv4Error &&
|
||||
!domainError &&
|
||||
recordValue !== "";
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalHeader
|
||||
title={record ? "Update DNS Record" : "Add DNS Record"}
|
||||
description={
|
||||
record
|
||||
? `Update record of '${zone.domain}' zone`
|
||||
: `Add new record to the '${zone.domain}' zone`
|
||||
}
|
||||
icon={<GlobeIcon size={16} />}
|
||||
/>
|
||||
<Separator />
|
||||
<div className={"px-8 py-6 flex flex-col gap-6"}>
|
||||
<div className={"flex items-center justify-between gap-10"}>
|
||||
<div>
|
||||
<Label>Record Type</Label>
|
||||
<HelpText className={"max-w-sm"}>
|
||||
Select the type of record you want to add
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"min-w-[130px]"}>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(v) => {
|
||||
setType(v as DNSRecordType);
|
||||
setRecordValue("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full pl-4"
|
||||
data-cy={"dns-record-type-select"}
|
||||
>
|
||||
<SelectValue placeholder="Select type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="A">A</SelectItem>
|
||||
<SelectItem value="AAAA">AAAA</SelectItem>
|
||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"w-full mb-3"}>
|
||||
<Label>Hostname</Label>
|
||||
<HelpText>
|
||||
Enter a subdomain or leave empty to use the primary domain.
|
||||
</HelpText>
|
||||
<div className={"flex w-full"}>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
placeholder={"Subdomain (leave empty for primary domain)"}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"bottom"}
|
||||
error={domainError}
|
||||
value={domain}
|
||||
className={"rounded-r-none"}
|
||||
maxWidthClass={"w-full"}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-900 rounded-r-md border text-nb-gray-300 border-l-0 text-sm border-nb-gray-700 flex items-center justify-center whitespace-nowrap px-4 opacity-80"
|
||||
}
|
||||
>
|
||||
.{zone.domain}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-4 items-start mb-3"}>
|
||||
{type === "A" && (
|
||||
<div className={"flex-1"}>
|
||||
<Label>IPv4 Address</Label>
|
||||
<Input
|
||||
className={"mt-1.5 font-mono text-[0.82rem]"}
|
||||
placeholder={"192.168.1.1"}
|
||||
errorTooltip={false}
|
||||
errorTooltipPosition={"top"}
|
||||
error={ipv4Error}
|
||||
value={recordValue}
|
||||
maxWidthClass={"w-full"}
|
||||
onChange={(e) => setRecordValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "AAAA" && (
|
||||
<div className={"flex-1"}>
|
||||
<Label>IPv6 Address</Label>
|
||||
<Input
|
||||
className={"mt-1.5 font-mono text-[0.82rem]"}
|
||||
placeholder={"2001:0db8:85a3::8a2e:0370:7334"}
|
||||
errorTooltip={false}
|
||||
errorTooltipPosition={"top"}
|
||||
error={ipv6Error}
|
||||
value={recordValue}
|
||||
maxWidthClass={"w-full"}
|
||||
onChange={(e) => setRecordValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "CNAME" && (
|
||||
<div className={"flex-1"}>
|
||||
<Label>Target Domain</Label>
|
||||
<Input
|
||||
className={"mt-1.5"}
|
||||
placeholder={"e.g., example.com or intra.example.com"}
|
||||
errorTooltip={false}
|
||||
errorTooltipPosition={"top"}
|
||||
error={cnameError}
|
||||
value={recordValue}
|
||||
maxWidthClass={"w-full"}
|
||||
onChange={(e) => setRecordValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={"min-w-[200px]"}>
|
||||
<Label>TTL (Time to Live)</Label>
|
||||
<div className={"mt-2.5"}>
|
||||
<Select value={ttl} onValueChange={(v) => setTtl(v)}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
data-cy={"dns-record-ttl-select"}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<ClockIcon size={14} className={"text-nb-gray-300"} />
|
||||
<SelectValue placeholder="Select TTL..." />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="60">{getTTLLabel(60)}</SelectItem>
|
||||
<SelectItem value="120">{getTTLLabel(120)}</SelectItem>
|
||||
<SelectItem value="300">{getTTLLabel(300)}</SelectItem>
|
||||
<SelectItem value="600">{getTTLLabel(600)}</SelectItem>
|
||||
<SelectItem value="900">{getTTLLabel(900)}</SelectItem>
|
||||
<SelectItem value="1800">{getTTLLabel(1800)}</SelectItem>
|
||||
<SelectItem value="3600">{getTTLLabel(3600)}</SelectItem>
|
||||
<SelectItem value="7200">{getTTLLabel(7200)}</SelectItem>
|
||||
<SelectItem value="43200">{getTTLLabel(43200)}</SelectItem>
|
||||
<SelectItem value="86400">{getTTLLabel(86400)}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink href={DNS_RECORDS_DOCS_LINK} target={"_blank"}>
|
||||
DNS Records
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={handleAddRecord}
|
||||
disabled={!canUpdateOrCreate}
|
||||
>
|
||||
{record ? "Save Changes" : "Add Record"}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export const getTTLLabel = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds} Sec.`;
|
||||
if (seconds < 3600) {
|
||||
const minutes = seconds / 60;
|
||||
return minutes === 1 ? "1 Min." : `${minutes} Min.`;
|
||||
}
|
||||
if (seconds < 86400) {
|
||||
const hours = seconds / 3600;
|
||||
return hours === 1 ? "1 Hour" : `${hours} Hours`;
|
||||
}
|
||||
const days = seconds / 86400;
|
||||
return days === 1 ? "1 Day" : `${days} Days`;
|
||||
};
|
||||
225
src/modules/dns/zones/DNSZoneModal.tsx
Normal file
225
src/modules/dns/zones/DNSZoneModal.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import Button from "@components/Button";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalTrigger,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { validator } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, Power, ScanSearch } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: (zone: DNSZone) => void;
|
||||
onSuccessAdded?: (zone: DNSZone) => void;
|
||||
initialDistributionGroups?: Group[];
|
||||
zone?: DNSZone;
|
||||
};
|
||||
|
||||
export default function DNSZoneModal({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
onSuccessAdded,
|
||||
initialDistributionGroups,
|
||||
zone,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
|
||||
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
|
||||
{open && (
|
||||
<DNSZoneModalContent
|
||||
onSuccess={(z) => {
|
||||
onOpenChange(false);
|
||||
onSuccess?.(z);
|
||||
}}
|
||||
onSuccessAdded={(z) => {
|
||||
onOpenChange(false);
|
||||
onSuccessAdded?.(z);
|
||||
}}
|
||||
zone={zone}
|
||||
initialDistributionGroups={initialDistributionGroups}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalProps = {
|
||||
onSuccess?: (zone: DNSZone) => void;
|
||||
onSuccessAdded?: (zone: DNSZone) => void;
|
||||
initialDistributionGroups?: Group[];
|
||||
zone?: DNSZone;
|
||||
};
|
||||
|
||||
export function DNSZoneModalContent({
|
||||
onSuccess,
|
||||
onSuccessAdded,
|
||||
zone,
|
||||
initialDistributionGroups,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { createZone, updateZone } = useDNSZones();
|
||||
const [domain, setDomain] = useState(zone?.domain ?? "");
|
||||
const [enabled, setEnabled] = useState<boolean>(zone?.enabled ?? true);
|
||||
const [searchDomainsEnabled, setSearchDomainsEnabled] = useState(
|
||||
zone?.enable_search_domain ?? false,
|
||||
);
|
||||
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
|
||||
initial: initialDistributionGroups ?? zone?.distribution_groups ?? [],
|
||||
});
|
||||
|
||||
const domainError = useMemo(() => {
|
||||
if (domain == "") return "";
|
||||
const valid = validator.isValidDomain(domain, {
|
||||
allowWildcard: false,
|
||||
allowOnlyTld: false,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. company.internal or intra.example.com";
|
||||
}
|
||||
}, [domain]);
|
||||
|
||||
const handleOnSubmit = async () => {
|
||||
return saveGroups().then((distributionGroups) => {
|
||||
const groupIds = distributionGroups.map((group) => group.id as string);
|
||||
|
||||
if (zone) {
|
||||
updateZone({
|
||||
id: zone.id,
|
||||
domain,
|
||||
name: domain,
|
||||
distribution_groups: groupIds,
|
||||
enabled,
|
||||
enable_search_domain: searchDomainsEnabled,
|
||||
} as DNSZone).then(onSuccess);
|
||||
} else {
|
||||
createZone({
|
||||
domain,
|
||||
name: domain,
|
||||
distribution_groups: groupIds,
|
||||
enabled,
|
||||
enable_search_domain: searchDomainsEnabled,
|
||||
} as DNSZone).then(onSuccessAdded);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const canUpdateOrCreate = !domainError && groups?.length > 0 && domain !== "";
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-2xl"}>
|
||||
<ModalHeader
|
||||
icon={<DNSZoneIcon size={20} className={"fill-netbird"} />}
|
||||
title={zone ? "Update DNS Zone" : "Add DNS Zone"}
|
||||
description={
|
||||
"Use a zone to control domain name resolution for your network."
|
||||
}
|
||||
color={"netbird"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 pt-6 pb-7 flex-col flex gap-6"}>
|
||||
<div>
|
||||
<Label>Domain</Label>
|
||||
<HelpText>
|
||||
Enter a domain for this zone (e.g., company.internal,
|
||||
intra.example.com)
|
||||
</HelpText>
|
||||
<Input
|
||||
disabled={!!zone}
|
||||
readOnly={!!zone}
|
||||
placeholder={"e.g., company.internal"}
|
||||
errorTooltip={false}
|
||||
errorTooltipPosition={"top"}
|
||||
error={domainError}
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={"mb-2"}>
|
||||
<Label>Distribution Groups</Label>
|
||||
<HelpText>
|
||||
Advertise this zone and its records to peers that belong to the
|
||||
following groups
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
onChange={setGroups}
|
||||
values={groups}
|
||||
showResources={false}
|
||||
showResourceCounter={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={searchDomainsEnabled}
|
||||
onChange={setSearchDomainsEnabled}
|
||||
label={
|
||||
<>
|
||||
<ScanSearch size={15} />
|
||||
Enable Search Domains
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"E.g., 'server.company.internal' will be accessible with 'server'"
|
||||
}
|
||||
/>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
label={
|
||||
<>
|
||||
<Power size={15} />
|
||||
Enable DNS Zone
|
||||
</>
|
||||
}
|
||||
helpText={"Use this switch to enable or disable the dns zone."}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
|
||||
DNS Zones
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={handleOnSubmit}
|
||||
disabled={!canUpdateOrCreate}
|
||||
>
|
||||
{zone ? "Save Changes" : "Add Zone"}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
264
src/modules/dns/zones/DNSZonesProvider.tsx
Normal file
264
src/modules/dns/zones/DNSZonesProvider.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { DNSRecord, DNSZone } from "@/interfaces/DNS";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import DNSRecordModal from "@/modules/dns/zones/DNSRecordModal";
|
||||
import DNSZoneModal from "@/modules/dns/zones/DNSZoneModal";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const DNSZonesContext = React.createContext(
|
||||
{} as {
|
||||
createZone: (zone: DNSZone) => Promise<DNSZone>;
|
||||
updateZone: (zone: DNSZone) => Promise<DNSZone>;
|
||||
deleteZone: (zone: DNSZone) => Promise<DNSZone>;
|
||||
openZoneModal: (
|
||||
zone?: DNSZone,
|
||||
initialDistributionGroups?: Group[],
|
||||
) => void;
|
||||
openRecordModal: (zone: DNSZone, record?: DNSRecord) => void;
|
||||
addRecord: (zone: DNSZone, record: DNSRecord) => Promise<DNSRecord>;
|
||||
updateRecord: (zone: DNSZone, record: DNSRecord) => Promise<DNSRecord>;
|
||||
deleteRecord: (zone: DNSZone, record: DNSRecord) => Promise<DNSRecord>;
|
||||
askForRecord: (zone: DNSZone) => void;
|
||||
},
|
||||
);
|
||||
|
||||
export const DNSZonesProvider = ({ children }: Props) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const zoneRequest = useApiCall<DNSZone>("/dns/zones", true);
|
||||
const recordRequest = useApiCall<DNSRecord>("/dns/zones", true);
|
||||
const [dnsModal, setDnsModal] = useState(false);
|
||||
const [recordModal, setRecordModal] = useState(false);
|
||||
const [currentZone, setCurrentZone] = useState<DNSZone>();
|
||||
const [currentRecord, setCurrentRecord] = useState<DNSRecord>();
|
||||
const [initialDistributionGroups, setInitialDistributionGroups] =
|
||||
useState<Group[]>();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
const createZone = async (zone: DNSZone): Promise<DNSZone> => {
|
||||
const promise = zoneRequest.post(zone).then((zone) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(zone);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `DNS Zone '${zone.domain}'`,
|
||||
description: `DNS Zone was added successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Adding DNS Zone...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const updateZone = async (zone: DNSZone): Promise<DNSZone> => {
|
||||
if (!zone?.id) return Promise.reject("Can not update DNS Zone without ID");
|
||||
const promise = zoneRequest.put(zone, `/${zone.id}`).then((zone) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(zone);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `DNS Zone '${zone.domain}'`,
|
||||
description: `DNS Zone was updated successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Updating DNS Zone...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const deleteZone = async (zone: DNSZone): Promise<DNSZone> => {
|
||||
if (!zone?.id) return Promise.reject("Can not delete DNS Zone without ID");
|
||||
|
||||
const choice = await confirm({
|
||||
title: `Delete zone '${zone.domain}'?`,
|
||||
description:
|
||||
"Are you sure you want to delete this zone? This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
maxWidthClass: "max-w-md",
|
||||
});
|
||||
if (!choice) return Promise.resolve(zone);
|
||||
|
||||
const promise = zoneRequest.del({}, `/${zone.id}`).then((zone) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(zone);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `DNS Zone '${zone.domain}'`,
|
||||
description: `DNS Zone was deleted successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Deleting DNS Zone...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const addRecord = async (
|
||||
zone: DNSZone,
|
||||
record: DNSRecord,
|
||||
): Promise<DNSRecord> => {
|
||||
if (!zone?.id)
|
||||
return Promise.reject("Can not add DNS Record without DNS Zone");
|
||||
const promise = recordRequest
|
||||
.post(record, `/${zone.id}/records`)
|
||||
.then((record) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(record);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `${record.type} Record '${record.name}'`,
|
||||
description: `DNS Record was added successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Adding DNS Record...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const updateRecord = async (
|
||||
zone: DNSZone,
|
||||
record: DNSRecord,
|
||||
): Promise<DNSRecord> => {
|
||||
if (!zone?.id)
|
||||
return Promise.reject("Can not update DNS Record without DNS Zone");
|
||||
if (!record?.id)
|
||||
return Promise.reject("Can not update DNS Record without ID");
|
||||
const promise = recordRequest
|
||||
.put(record, `/${zone.id}/records/${record.id}`)
|
||||
.then((record) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(record);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `${record.type} Record '${record.name}'`,
|
||||
description: `DNS Record was updated successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Updating DNS Record...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const deleteRecord = async (
|
||||
zone: DNSZone,
|
||||
record: DNSRecord,
|
||||
): Promise<DNSRecord> => {
|
||||
if (!zone?.id)
|
||||
return Promise.reject("Can not delete DNS Record without DNS Zone");
|
||||
if (!record?.id)
|
||||
return Promise.reject("Can not delete DNS Record without ID");
|
||||
|
||||
const choice = await confirm({
|
||||
title: `Delete record '${record.name}'?`,
|
||||
description:
|
||||
"Are you sure you want to delete this record? This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
maxWidthClass: "max-w-md",
|
||||
});
|
||||
if (!choice) return Promise.resolve(record);
|
||||
|
||||
const promise = recordRequest
|
||||
.del({}, `/${zone.id}/records/${record.id}`)
|
||||
.then((record) => {
|
||||
mutate("/dns/zones");
|
||||
return Promise.resolve(record);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `${record.type} Record '${record.name}'`,
|
||||
description: `DNS Record was deleted successfully.`,
|
||||
promise: promise,
|
||||
loadingMessage: "Deleting DNS Record...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const openZoneModal = (zone?: DNSZone, distributionGroups?: Group[]) => {
|
||||
if (zone) setCurrentZone(zone);
|
||||
if (distributionGroups) setInitialDistributionGroups(distributionGroups);
|
||||
setDnsModal(true);
|
||||
};
|
||||
|
||||
const openRecordModal = (zone: DNSZone, record?: DNSRecord) => {
|
||||
setCurrentZone(zone);
|
||||
if (record) setCurrentRecord(record);
|
||||
setRecordModal(true);
|
||||
};
|
||||
|
||||
const askForRecord = async (zone: DNSZone) => {
|
||||
const choice = await confirm({
|
||||
title: `Add new record to '${zone.name}'?`,
|
||||
description:
|
||||
"Add either an A, AAAA or a CNAME record to control domain name resolution for your network.",
|
||||
confirmText: "Add Record",
|
||||
cancelText: "Later",
|
||||
type: "default",
|
||||
maxWidthClass: "max-w-md",
|
||||
});
|
||||
if (!choice) return;
|
||||
openRecordModal(zone);
|
||||
};
|
||||
|
||||
return (
|
||||
<DNSZonesContext.Provider
|
||||
value={{
|
||||
createZone,
|
||||
updateZone,
|
||||
deleteZone,
|
||||
openZoneModal,
|
||||
openRecordModal,
|
||||
addRecord,
|
||||
updateRecord,
|
||||
deleteRecord,
|
||||
askForRecord,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<DNSZoneModal
|
||||
open={dnsModal}
|
||||
onOpenChange={(open) => {
|
||||
setDnsModal(open);
|
||||
if (!open) {
|
||||
setCurrentZone(undefined);
|
||||
setInitialDistributionGroups(undefined);
|
||||
}
|
||||
}}
|
||||
onSuccessAdded={(z) => askForRecord(z)}
|
||||
zone={currentZone}
|
||||
initialDistributionGroups={initialDistributionGroups}
|
||||
/>
|
||||
{currentZone && (
|
||||
<DNSRecordModal
|
||||
open={recordModal}
|
||||
onOpenChange={(open) => {
|
||||
setRecordModal(open);
|
||||
if (!open) {
|
||||
setCurrentZone(undefined);
|
||||
setCurrentRecord(undefined);
|
||||
}
|
||||
}}
|
||||
zone={currentZone}
|
||||
record={currentRecord}
|
||||
/>
|
||||
)}
|
||||
</DNSZonesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useDNSZones = () => React.useContext(DNSZonesContext);
|
||||
40
src/modules/dns/zones/records/DNSRecordActionCell.tsx
Normal file
40
src/modules/dns/zones/records/DNSRecordActionCell.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import Button from "@components/Button";
|
||||
import { PenSquare, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSRecord } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import { useDNSZone } from "@/modules/dns/zones/records/DNSRecordsTable";
|
||||
|
||||
type Props = {
|
||||
record: DNSRecord;
|
||||
};
|
||||
|
||||
export const DNSRecordActionCell = ({ record }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { deleteRecord, openRecordModal } = useDNSZones();
|
||||
const zone = useDNSZone();
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<Button
|
||||
variant={"default-outline"}
|
||||
size={"sm"}
|
||||
onClick={() => openRecordModal(zone, record)}
|
||||
disabled={!permission?.dns?.update}
|
||||
>
|
||||
<PenSquare size={16} />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant={"danger-outline"}
|
||||
size={"sm"}
|
||||
onClick={() => deleteRecord(zone, record)}
|
||||
disabled={!permission?.dns?.delete}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
src/modules/dns/zones/records/DNSRecordContentCell.tsx
Normal file
19
src/modules/dns/zones/records/DNSRecordContentCell.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
import * as React from "react";
|
||||
import { DNSRecord } from "@/interfaces/DNS";
|
||||
|
||||
type Props = {
|
||||
record: DNSRecord;
|
||||
};
|
||||
|
||||
export const DNSRecordContentCell = ({ record }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate font-mono">
|
||||
<CopyToClipboardText>
|
||||
<span className={"font-normal truncate text-[0.82rem]"}>
|
||||
{record.content}
|
||||
</span>
|
||||
</CopyToClipboardText>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
17
src/modules/dns/zones/records/DNSRecordNameCell.tsx
Normal file
17
src/modules/dns/zones/records/DNSRecordNameCell.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
import * as React from "react";
|
||||
import { DNSRecord } from "@/interfaces/DNS";
|
||||
|
||||
type Props = {
|
||||
record: DNSRecord;
|
||||
};
|
||||
|
||||
export const DNSRecordNameCell = ({ record }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate">
|
||||
<CopyToClipboardText>
|
||||
<span className={"font-normal truncate"}>{record.name}</span>
|
||||
</CopyToClipboardText>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
21
src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx
Normal file
21
src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ClockIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { DNSRecord } from "@/interfaces/DNS";
|
||||
import { getTTLLabel } from "@/modules/dns/zones/DNSRecordModal";
|
||||
|
||||
type Props = {
|
||||
record: DNSRecord;
|
||||
};
|
||||
|
||||
export const DNSRecordTimeToLiveCell = ({ record }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex items-center whitespace-nowrap gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all py-2 px-3 rounded-md"
|
||||
}
|
||||
>
|
||||
<ClockIcon size={14} />
|
||||
{getTTLLabel(record.ttl)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
src/modules/dns/zones/records/DNSRecordTypeCell.tsx
Normal file
20
src/modules/dns/zones/records/DNSRecordTypeCell.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import Badge from "@components/Badge";
|
||||
import * as React from "react";
|
||||
import { DNSRecord } from "@/interfaces/DNS";
|
||||
|
||||
type Props = {
|
||||
record: DNSRecord;
|
||||
};
|
||||
|
||||
export const DNSRecordTypeCell = ({ record }: Props) => {
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium"}
|
||||
>
|
||||
{record.type}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
80
src/modules/dns/zones/records/DNSRecordsTable.tsx
Normal file
80
src/modules/dns/zones/records/DNSRecordsTable.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
import { DNSRecord, DNSZone } from "@/interfaces/DNS";
|
||||
import { DNSRecordActionCell } from "@/modules/dns/zones/records/DNSRecordActionCell";
|
||||
import { DNSRecordContentCell } from "@/modules/dns/zones/records/DNSRecordContentCell";
|
||||
import { DNSRecordNameCell } from "@/modules/dns/zones/records/DNSRecordNameCell";
|
||||
import { DNSRecordTimeToLiveCell } from "@/modules/dns/zones/records/DNSRecordTimeToLiveCell";
|
||||
import { DNSRecordTypeCell } from "@/modules/dns/zones/records/DNSRecordTypeCell";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSRecordsTableColumns: ColumnDef<DNSRecord>[] = [
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Type</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <DNSRecordTypeCell record={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Hostname</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <DNSRecordNameCell record={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Content</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <DNSRecordContentCell record={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "ttl",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>TTL</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <DNSRecordTimeToLiveCell record={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ row }) => <DNSRecordActionCell record={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
const ZoneContext = createContext({} as DNSZone);
|
||||
|
||||
export default function DNSRecordsTable({ zone }: Props) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
return (
|
||||
<ZoneContext.Provider value={zone}>
|
||||
<DataTable
|
||||
uniqueKey={zone.id}
|
||||
keepStateInLocalStorage={false}
|
||||
tableClassName={"mt-0"}
|
||||
minimal={true}
|
||||
showSearchAndFilters={false}
|
||||
rowClassName={"last:pb-10"}
|
||||
className={"bg-nb-gray-960 py-2"}
|
||||
inset={true}
|
||||
text={"DNS Records"}
|
||||
manualPagination={true}
|
||||
sorting={sorting}
|
||||
columnVisibility={{}}
|
||||
setSorting={setSorting}
|
||||
columns={DNSRecordsTableColumns}
|
||||
data={zone.records}
|
||||
/>
|
||||
</ZoneContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useDNSZone = () => useContext(ZoneContext);
|
||||
58
src/modules/dns/zones/table/DNSZonesActionCell.tsx
Normal file
58
src/modules/dns/zones/table/DNSZonesActionCell.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import { MoreVertical, SquarePenIcon, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesActionCell = ({ zone }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { openZoneModal, deleteZone } = useDNSZones();
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Button variant={"secondary"} className={"!px-3"}>
|
||||
<MoreVertical size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-auto" align="end">
|
||||
<DropdownMenuItem onClick={() => openZoneModal(zone)}>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<SquarePenIcon size={14} className={"shrink-0"} />
|
||||
Edit
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteZone(zone)}
|
||||
variant={"danger"}
|
||||
disabled={!permission?.dns?.delete}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<Trash2 size={14} className={"shrink-0"} />
|
||||
Delete
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
src/modules/dns/zones/table/DNSZonesActiveCell.tsx
Normal file
32
src/modules/dns/zones/table/DNSZonesActiveCell.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesActiveCell = ({ zone }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { updateZone } = useDNSZones();
|
||||
|
||||
return (
|
||||
<div className={"flex min-w-[0px]"}>
|
||||
<ToggleSwitch
|
||||
disabled={!permission?.dns?.update}
|
||||
checked={zone.enabled}
|
||||
size={"small"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateZone({
|
||||
...zone,
|
||||
enabled: !zone.enabled,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
src/modules/dns/zones/table/DNSZonesGroupCell.tsx
Normal file
60
src/modules/dns/zones/table/DNSZonesGroupCell.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
import GroupsRow from "@/modules/common-table-rows/GroupsRow";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesGroupCell = ({ zone }: Props) => {
|
||||
const { groups } = useGroups();
|
||||
const { updateZone } = useDNSZones();
|
||||
const [modal, setModal] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const allGroups = zone?.distribution_groups
|
||||
.map((group) => {
|
||||
return groups?.find((g) => g.id == group);
|
||||
})
|
||||
.filter((g) => g != undefined) as Group[];
|
||||
|
||||
const groupIDs = useMemo(() => {
|
||||
return allGroups
|
||||
?.map((group) => group.id)
|
||||
.filter((id) => id !== undefined) as string[];
|
||||
}, [allGroups]);
|
||||
|
||||
const handleSave = async (promises: Promise<Group>[]) => {
|
||||
const groups = await Promise.all(promises);
|
||||
const groupIds = groups?.map((g) => g.id as string);
|
||||
await updateZone({
|
||||
...zone,
|
||||
distribution_groups: groupIds,
|
||||
}).then(() => {
|
||||
setModal(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!zone?.distribution_groups) return <EmptyRow />;
|
||||
|
||||
return (
|
||||
<GroupsRow
|
||||
label={"Distribution Groups"}
|
||||
description={
|
||||
"Advertise this zone to peers that belong to the following groups"
|
||||
}
|
||||
groups={groupIDs || []}
|
||||
hideAllGroup={false}
|
||||
disabled={!permission?.dns?.update}
|
||||
onSave={handleSave}
|
||||
modal={modal}
|
||||
setModal={setModal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
38
src/modules/dns/zones/table/DNSZonesNameCell.tsx
Normal file
38
src/modules/dns/zones/table/DNSZonesNameCell.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ChevronDown, ChevronRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesNameCell = ({ zone }: Props) => {
|
||||
const hasRecords = (zone?.records?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className={"flex gap-6 items-center min-w-[270px] max-w-[270px]"}>
|
||||
<ChevronRightIcon
|
||||
size={20}
|
||||
className={cn(
|
||||
"group-data-[accordion=opened]/accordion:hidden text-nb-gray-400 shrink-0",
|
||||
!hasRecords && "cursor-default opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className={cn(
|
||||
"group-data-[accordion=closed]/accordion:hidden text-nb-gray-400 shrink-0",
|
||||
!hasRecords && "cursor-default opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ActiveInactiveRow
|
||||
active={zone.enabled}
|
||||
inactiveDot={"gray"}
|
||||
text={zone.domain}
|
||||
dataCy={zone.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
src/modules/dns/zones/table/DNSZonesRecordsCell.tsx
Normal file
47
src/modules/dns/zones/table/DNSZonesRecordsCell.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import { GlobeIcon, PlusCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesRecordsCell = ({ zone }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { openRecordModal } = useDNSZones();
|
||||
|
||||
const recordsCount = zone?.records?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className={"flex gap-3"}>
|
||||
{recordsCount > 0 && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={true}
|
||||
className={"cursor-pointer"}
|
||||
onClick={() => void 0}
|
||||
>
|
||||
<GlobeIcon size={12} />
|
||||
<div>
|
||||
<span className={"font-medium text-xs"}>{recordsCount}</span>
|
||||
</div>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
className={"min-w-[130px]"}
|
||||
onClick={() => openRecordModal(zone)}
|
||||
disabled={!permission?.dns?.create}
|
||||
>
|
||||
<PlusCircle size={12} />
|
||||
Add Record
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx
Normal file
32
src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
|
||||
type Props = {
|
||||
zone: DNSZone;
|
||||
};
|
||||
|
||||
export const DNSZonesSearchDomainCell = ({ zone }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { updateZone } = useDNSZones();
|
||||
|
||||
return (
|
||||
<div className={"flex min-w-[0px]"}>
|
||||
<ToggleSwitch
|
||||
disabled={!permission?.dns?.update}
|
||||
checked={zone?.enable_search_domain}
|
||||
size={"small"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateZone({
|
||||
...zone,
|
||||
enable_search_domain: !zone.enable_search_domain,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
303
src/modules/dns/zones/table/DNSZonesTable.tsx
Normal file
303
src/modules/dns/zones/table/DNSZonesTable.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import Card from "@components/Card";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useMemo } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS";
|
||||
import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import DNSRecordsTable from "@/modules/dns/zones/records/DNSRecordsTable";
|
||||
import { DNSZonesActionCell } from "@/modules/dns/zones/table/DNSZonesActionCell";
|
||||
import { DNSZonesActiveCell } from "@/modules/dns/zones/table/DNSZonesActiveCell";
|
||||
import { DNSZonesGroupCell } from "@/modules/dns/zones/table/DNSZonesGroupCell";
|
||||
import { DNSZonesNameCell } from "@/modules/dns/zones/table/DNSZonesNameCell";
|
||||
import { DNSZonesRecordsCell } from "@/modules/dns/zones/table/DNSZonesRecordsCell";
|
||||
import { DNSZonesSearchDomainCell } from "@/modules/dns/zones/table/DNSZonesSearchDomainCell";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
|
||||
export const DNSZonesColumns: ColumnDef<DNSZone>[] = [
|
||||
{
|
||||
accessorKey: "domain",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Zone</DataTableHeader>
|
||||
),
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <DNSZonesNameCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Active</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <DNSZonesActiveCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "records",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Records</DataTableHeader>
|
||||
),
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <DNSZonesRecordsCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "distribution_groups",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Distribution Groups</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <DNSZonesGroupCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "enable_search_domain",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Search Domain</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <DNSZonesSearchDomainCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: () => "",
|
||||
cell: ({ row }) => <DNSZonesActionCell zone={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "searchString",
|
||||
accessorFn: (row) => {
|
||||
return [
|
||||
row?.groups_search,
|
||||
row?.name,
|
||||
row?.domain,
|
||||
row?.records?.map((r) => r.name).join(""),
|
||||
row?.records?.map((r) => r.content).join(""),
|
||||
row?.records?.map((r) => r.type).join(""),
|
||||
]?.join("");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
isLoading: boolean;
|
||||
data?: DNSZone[];
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
isGroupPage?: boolean;
|
||||
distributionGroups?: Group[];
|
||||
};
|
||||
|
||||
export default function DNSZonesTable({
|
||||
data,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
isGroupPage = false,
|
||||
distributionGroups,
|
||||
}: Props) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const path = usePathname();
|
||||
const { groups } = useGroups();
|
||||
|
||||
// Default sorting state of the table
|
||||
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
||||
"netbird-table-sort" + path,
|
||||
[
|
||||
{
|
||||
id: "domain",
|
||||
desc: true,
|
||||
},
|
||||
{
|
||||
id: "id",
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
!isGroupPage,
|
||||
);
|
||||
|
||||
const zonesWithGroups = useMemo(() => {
|
||||
return (
|
||||
data?.map((zone) => {
|
||||
return {
|
||||
...zone,
|
||||
groups_search: groups
|
||||
?.map((g) =>
|
||||
zone?.distribution_groups?.includes(g?.id ?? "") ? g.name : "",
|
||||
)
|
||||
.join(""),
|
||||
} as DNSZone;
|
||||
}) ?? []
|
||||
);
|
||||
}, [data, groups]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
isLoading={isLoading}
|
||||
text={"DNS Zones"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={DNSZonesColumns}
|
||||
data={zonesWithGroups}
|
||||
useRowId={true}
|
||||
wrapperComponent={isGroupPage ? Card : undefined}
|
||||
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
|
||||
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
|
||||
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
|
||||
inset={false}
|
||||
minimal={isGroupPage}
|
||||
keepStateInLocalStorage={!isGroupPage}
|
||||
searchPlaceholder={"Search by domain, ip, content or group..."}
|
||||
columnVisibility={{ searchString: false }}
|
||||
renderExpandedRow={(zone) => {
|
||||
const hasRecords = (zone?.records?.length ?? 0) > 0;
|
||||
if (!hasRecords) return;
|
||||
return (
|
||||
<>
|
||||
<DNSRecordsTable zone={zone} />
|
||||
<div className={"h-2 w-full bg-nb-gray-960"}></div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
getStartedCard={
|
||||
isGroupPage ? (
|
||||
<NoResults
|
||||
icon={<DNSZoneIcon className={"fill-nb-gray-200"} size={24} />}
|
||||
className={"py-4"}
|
||||
contentClassName={"max-w-lg"}
|
||||
title={"This group is not used within any zones yet"}
|
||||
description={
|
||||
"Assign this group as a distribution group in your zones to see them listed here."
|
||||
}
|
||||
>
|
||||
<div className={"gap-x-4 flex items-center justify-center mt-4"}>
|
||||
<AddZoneButton distributionGroups={distributionGroups} />
|
||||
</div>
|
||||
</NoResults>
|
||||
) : (
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={<DNSZoneIcon className={"fill-nb-gray-200"} size={24} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Zone"}
|
||||
description={
|
||||
"It looks like you don't have any zones. Control domain name resolution for your network by adding a zone."
|
||||
}
|
||||
button={
|
||||
<div className={"gap-x-4 flex items-center justify-center"}>
|
||||
<AddZoneButton distributionGroups={distributionGroups} />
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
|
||||
DNS Zones
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
rightSide={() => (
|
||||
<>
|
||||
{data && data?.length > 0 && (
|
||||
<div className={"gap-x-4 ml-auto flex"}>
|
||||
<AddZoneButton distributionGroups={distributionGroups} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<ButtonGroup disabled={data?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(undefined);
|
||||
}}
|
||||
disabled={data?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(true);
|
||||
}}
|
||||
disabled={data?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Active
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(false);
|
||||
}}
|
||||
disabled={data?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === false
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Inactive
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
<DataTableRowsPerPage table={table} disabled={data?.length == 0} />
|
||||
<DataTableRefreshButton
|
||||
isDisabled={data?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/dns/zones").then();
|
||||
mutate("/groups").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
||||
type AddZoneButtonProps = {
|
||||
distributionGroups?: Group[];
|
||||
};
|
||||
|
||||
const AddZoneButton = ({ distributionGroups }: AddZoneButtonProps) => {
|
||||
const { permission } = usePermissions();
|
||||
const { openZoneModal } = useDNSZones();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={""}
|
||||
disabled={!permission?.dns?.create}
|
||||
onClick={() => openZoneModal(undefined, distributionGroups)}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Zone
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
29
src/modules/groups/details/GroupDNSZonesSection.tsx
Normal file
29
src/modules/groups/details/GroupDNSZonesSection.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import DNSZonesTable from "@/modules/dns/zones/table/DNSZonesTable";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
|
||||
export const GroupDNSZonesSection = ({
|
||||
zones,
|
||||
isLoading = true,
|
||||
}: {
|
||||
zones?: DNSZone[];
|
||||
isLoading?: boolean;
|
||||
}) => {
|
||||
const { group } = useGroupContext();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<DNSZonesProvider>
|
||||
<DNSZonesTable
|
||||
isGroupPage={true}
|
||||
isLoading={isLoading}
|
||||
data={zones}
|
||||
distributionGroups={[group]}
|
||||
/>
|
||||
</DNSZonesProvider>
|
||||
</GroupDetailsTableContainer>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import React, { lazy } from "react";
|
||||
import { useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
|
||||
|
||||
const NameserverGroupTable = lazy(
|
||||
() => import("@/modules/dns-nameservers/table/NameserverGroupTable"),
|
||||
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
|
||||
);
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import useFetchApi from "@/utils/api";
|
||||
export interface GroupDetails extends Group {
|
||||
policies: Policy[];
|
||||
nameservers: NameserverGroup[];
|
||||
zones?: DNSZone[];
|
||||
routes: Route[];
|
||||
setupKeys: SetupKey[];
|
||||
users: User[];
|
||||
@@ -31,6 +33,8 @@ export default function useGroupDetails(groupId: string) {
|
||||
useFetchApi<Policy[]>(`/policies`);
|
||||
const { data: nameservers, isLoading: isNameserversLoading } =
|
||||
useFetchApi<NameserverGroup[]>(`/dns/nameservers`);
|
||||
const { data: zones, isLoading: isZonesLoading } =
|
||||
useFetchApi<DNSZone[]>(`/dns/zones`);
|
||||
const { data: routes, isLoading: isRoutesLoading } =
|
||||
useFetchApi<Route[]>(`/routes`);
|
||||
const { data: setupKeys, isLoading: isSetupKeysLoading } =
|
||||
@@ -65,6 +69,12 @@ export default function useGroupDetails(groupId: string) {
|
||||
return nameservers?.filter((ns) => ns.groups?.includes(groupId)) || [];
|
||||
}, [nameservers, groupId]);
|
||||
|
||||
const linkedZones = useMemo(() => {
|
||||
return (
|
||||
zones?.filter((ns) => ns.distribution_groups?.includes(groupId)) || []
|
||||
);
|
||||
}, [zones, groupId]);
|
||||
|
||||
const linkedRoutes = useMemo(() => {
|
||||
return (
|
||||
routes?.filter((route) => {
|
||||
@@ -117,6 +127,7 @@ export default function useGroupDetails(groupId: string) {
|
||||
isGroupsLoading ||
|
||||
isPoliciesLoading ||
|
||||
isNameserversLoading ||
|
||||
isZonesLoading ||
|
||||
isRoutesLoading ||
|
||||
isSetupKeysLoading ||
|
||||
isUsersLoading ||
|
||||
@@ -131,6 +142,7 @@ export default function useGroupDetails(groupId: string) {
|
||||
...group,
|
||||
policies: linkedPolicies,
|
||||
nameservers: linkedNameservers,
|
||||
zones: linkedZones,
|
||||
routes: linkedRoutes,
|
||||
setupKeys: linkedSetupKeys,
|
||||
users: linkedUsers,
|
||||
@@ -142,6 +154,7 @@ export default function useGroupDetails(groupId: string) {
|
||||
group,
|
||||
linkedPolicies,
|
||||
linkedNameservers,
|
||||
linkedZones,
|
||||
linkedRoutes,
|
||||
linkedSetupKeys,
|
||||
linkedUsers,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -19,7 +20,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";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
|
||||
export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
{
|
||||
@@ -178,6 +179,28 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "zones_count",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
tooltip={<div className={"text-xs normal-case"}>Zones</div>}
|
||||
>
|
||||
<DNSZoneIcon size={16} />
|
||||
</DataTableHeader>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupsCountCell
|
||||
icon={<DNSZoneIcon size={14} />}
|
||||
groupName={row.original.name}
|
||||
href={`/group?id=${row.original.id}&tab=zones`}
|
||||
text={"Zone(s)"}
|
||||
count={row.original.zones_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "setup_keys_count",
|
||||
header: ({ column }) => {
|
||||
@@ -216,7 +239,8 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
row.routes_count > 0 ||
|
||||
row.setup_keys_count > 0 ||
|
||||
row.users_count > 0 ||
|
||||
row.resources_count > 0
|
||||
row.resources_count > 0 ||
|
||||
row.zones_count
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import useFetchApi from "@utils/api";
|
||||
import { useMemo } from "react";
|
||||
import { DNSZone } from "@/interfaces/DNS";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
@@ -11,6 +12,7 @@ export interface GroupUsage extends Group {
|
||||
peers_count: number;
|
||||
policies_count: number;
|
||||
nameservers_count: number;
|
||||
zones_count: number;
|
||||
routes_count: number;
|
||||
setup_keys_count: number;
|
||||
users_count: number;
|
||||
@@ -24,6 +26,8 @@ export default function useGroupsUsage() {
|
||||
useFetchApi<Policy[]>(`/policies`); // Policies
|
||||
const { data: nameservers, isLoading: isNameserversLoading } =
|
||||
useFetchApi<NameserverGroup[]>(`/dns/nameservers`); // DNS
|
||||
const { data: zones, isLoading: isZonesLoading } =
|
||||
useFetchApi<DNSZone[]>(`/dns/zones`); // DNS Zones
|
||||
const { data: routes, isLoading: isRoutesLoading } =
|
||||
useFetchApi<Route[]>(`/routes`); // Routes
|
||||
const { data: setupKeys, isLoading: isSetupKeysLoading } =
|
||||
@@ -57,6 +61,14 @@ export default function useGroupsUsage() {
|
||||
.filter((u) => u !== undefined);
|
||||
}, [nameservers, isNameserversLoading]);
|
||||
|
||||
const zonesGroups = useMemo(() => {
|
||||
if (isZonesLoading) return;
|
||||
if (!zones) return [];
|
||||
return zones
|
||||
?.map((zone) => zone.distribution_groups)
|
||||
.filter((u) => u !== undefined);
|
||||
}, [zones, isZonesLoading]);
|
||||
|
||||
const setupKeysGroups = useMemo(() => {
|
||||
if (isSetupKeysLoading) return;
|
||||
if (!setupKeys) return [];
|
||||
@@ -78,6 +90,7 @@ export default function useGroupsUsage() {
|
||||
isGroupsLoading ||
|
||||
isPoliciesLoading ||
|
||||
isNameserversLoading ||
|
||||
isZonesLoading ||
|
||||
isRoutesLoading ||
|
||||
isSetupKeysLoading ||
|
||||
isUsersLoading
|
||||
@@ -86,6 +99,7 @@ export default function useGroupsUsage() {
|
||||
isGroupsLoading,
|
||||
isPoliciesLoading,
|
||||
isNameserversLoading,
|
||||
isZonesLoading,
|
||||
isRoutesLoading,
|
||||
isSetupKeysLoading,
|
||||
isUsersLoading,
|
||||
@@ -104,6 +118,10 @@ export default function useGroupsUsage() {
|
||||
return nameserver.includes(group.id as string);
|
||||
}).length;
|
||||
|
||||
const zonesCount = zonesGroups?.filter((zone) => {
|
||||
return zone.includes(group.id as string);
|
||||
}).length;
|
||||
|
||||
const routeCount = (
|
||||
routes?.filter((route) => {
|
||||
const groupId = group.id as string;
|
||||
@@ -133,6 +151,7 @@ export default function useGroupsUsage() {
|
||||
resources_count: group.resources_count,
|
||||
policies_count: policyCount,
|
||||
nameservers_count: nameserverCount,
|
||||
zones_count: zonesCount,
|
||||
routes_count: routeCount,
|
||||
setup_keys_count: setupKeyCount,
|
||||
users_count: userCount,
|
||||
@@ -143,6 +162,7 @@ export default function useGroupsUsage() {
|
||||
groups,
|
||||
policiesGroups,
|
||||
nameserversGroups,
|
||||
zonesGroups,
|
||||
routes,
|
||||
isRoutesLoading,
|
||||
setupKeysGroups,
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
105
src/modules/peer/PeerExpirationSettings.tsx
Normal file
105
src/modules/peer/PeerExpirationSettings.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
|
||||
import { usePeer } from "@/contexts/PeerProvider";
|
||||
import { TimerResetIcon } from "lucide-react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
|
||||
export const PeerExpirationSettings = () => {
|
||||
const { peer, update } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const { mutate } = useSWRConfig();
|
||||
const account = useAccount();
|
||||
|
||||
const [peerLoginExpiration, setPeerLoginExpiration] = useState(
|
||||
peer.login_expiration_enabled,
|
||||
);
|
||||
const [peerInactivityExpiration, setPeerInactivityExpiration] = useState(
|
||||
peer.inactivity_expiration_enabled,
|
||||
);
|
||||
|
||||
const updateExpiration = async ({
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
}: {
|
||||
loginExpiration?: boolean;
|
||||
inactivityExpiration?: boolean;
|
||||
}) => {
|
||||
if (!permission?.peers.update) return;
|
||||
|
||||
const promise = update({
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
}).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: peer.name,
|
||||
description: "Expiration was successfully updated",
|
||||
promise,
|
||||
loadingMessage: "Updating setting...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const isAccountInactivityExpirationDisabled =
|
||||
account && account?.settings?.peer_inactivity_expiration_enabled === false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
value={peerLoginExpiration}
|
||||
icon={<TimerResetIcon size={16} />}
|
||||
type={"login-expiration"}
|
||||
onChange={async (state) => {
|
||||
setPeerLoginExpiration(state);
|
||||
!state && setPeerInactivityExpiration(false);
|
||||
|
||||
await updateExpiration({
|
||||
loginExpiration: state,
|
||||
inactivityExpiration: !state ? false : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{permission?.peers.update && !!peer?.user_id && (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
|
||||
!peerLoginExpiration
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "bg-nb-gray-930/80",
|
||||
isAccountInactivityExpirationDisabled &&
|
||||
"opacity-50 bg-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
variant={"blank"}
|
||||
type={"inactivity-expiration"}
|
||||
value={peerInactivityExpiration}
|
||||
onChange={async (state) => {
|
||||
setPeerInactivityExpiration(state);
|
||||
await updateExpiration({
|
||||
inactivityExpiration: state,
|
||||
});
|
||||
}}
|
||||
title={"Require login after disconnect"}
|
||||
description={
|
||||
"Enable to require authentication after users disconnect from management for 10 minutes."
|
||||
}
|
||||
className={
|
||||
!peerLoginExpiration ? "opacity-40 pointer-events-none" : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,10 +3,13 @@ import FancyToggleSwitch, {
|
||||
} from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, LockIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
@@ -16,6 +19,7 @@ type Props = {
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
type?: "login-expiration" | "inactivity-expiration";
|
||||
} & FancyToggleSwitchVariants;
|
||||
|
||||
export const PeerExpirationToggle = ({
|
||||
@@ -27,12 +31,26 @@ export const PeerExpirationToggle = ({
|
||||
icon,
|
||||
className,
|
||||
variant = "default",
|
||||
type = "login-expiration",
|
||||
}: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const account = useAccount();
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
const noPermissionOrNoUser = !peer.user_id || !permission?.peers.update;
|
||||
|
||||
const isAccountLoginExpirationDisabled =
|
||||
account && account?.settings?.peer_login_expiration_enabled === false;
|
||||
const isAccountInactivityExpirationDisabled =
|
||||
account && account?.settings?.peer_inactivity_expiration_enabled === false;
|
||||
|
||||
const isGlobalSettingDisabled =
|
||||
type === "login-expiration"
|
||||
? isAccountLoginExpirationDisabled
|
||||
: isAccountInactivityExpirationDisabled;
|
||||
|
||||
const tooltipContent = useMemo(() => {
|
||||
if (noPermissionOrNoUser) {
|
||||
return (
|
||||
<div className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}>
|
||||
{!peer.user_id ? (
|
||||
<>
|
||||
@@ -50,14 +68,37 @@ export const PeerExpirationToggle = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isGlobalSettingDisabled) {
|
||||
const text =
|
||||
type === "login-expiration"
|
||||
? "'Peer Session Expiration'"
|
||||
: "'Require login after disconnect'";
|
||||
return (
|
||||
<div className={"flex flex-col gap-2 text-xs max-w-xs"}>
|
||||
<div>
|
||||
Global setting {text} is currently disabled. Enable the global
|
||||
setting to be able to toggle it individually per peer.{" "}
|
||||
<InlineLink href={"/settings"}>
|
||||
Go to Settings <ArrowUpRightIcon size={12} />
|
||||
</InlineLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [noPermissionOrNoUser, peer, type, isGlobalSettingDisabled]);
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={tooltipContent}
|
||||
className={"w-full block"}
|
||||
disabled={!!peer.user_id && permission.peers.update}
|
||||
disabled={tooltipContent === undefined}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
className={className}
|
||||
disabled={!peer.user_id || !permission.peers.update}
|
||||
value={value}
|
||||
disabled={isGlobalSettingDisabled || noPermissionOrNoUser}
|
||||
value={isGlobalSettingDisabled ? false : value}
|
||||
onChange={onChange}
|
||||
variant={variant}
|
||||
label={
|
||||
|
||||
@@ -37,7 +37,12 @@ import { isNetbirdSSHProtocolSupported } from "@utils/version";
|
||||
export const PeerSSHToggle = () => {
|
||||
const { permission } = usePermissions();
|
||||
const { peer, toggleSSH, setSSHInstructionsModal } = usePeer();
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
const { data: policies } = useFetchApi<Policy[]>(
|
||||
"/policies",
|
||||
true,
|
||||
true,
|
||||
permission?.policies.read,
|
||||
);
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
const [sshPolicyModal, setSshPolicyModal] = useState(false);
|
||||
@@ -201,7 +206,11 @@ export const PeerSSHToggle = () => {
|
||||
|
||||
<div className={"flex gap-3"}>
|
||||
{isSSHClientEnabled ? (
|
||||
<Button variant={"secondary"} onClick={() => setSshPolicyModal(true)}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setSshPolicyModal(true)}
|
||||
disabled={!permission?.policies.create}
|
||||
>
|
||||
<CirclePlusIcon size={14} />
|
||||
Create SSH Policy
|
||||
</Button>
|
||||
@@ -301,29 +310,31 @@ export const PeerSSHToggle = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PoliciesProvider>
|
||||
<Modal
|
||||
open={policyModal}
|
||||
onOpenChange={(state) => {
|
||||
setPolicyModal(state);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
>
|
||||
<AccessControlModalContent
|
||||
key={policyModal ? "1" : "0"}
|
||||
policy={currentPolicy}
|
||||
onSuccess={async (p) => {
|
||||
setPolicyModal(false);
|
||||
{permission?.policies.create && (
|
||||
<PoliciesProvider>
|
||||
<Modal
|
||||
open={policyModal}
|
||||
onOpenChange={(state) => {
|
||||
setPolicyModal(state);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
>
|
||||
<AccessControlModalContent
|
||||
key={policyModal ? "1" : "0"}
|
||||
policy={currentPolicy}
|
||||
onSuccess={async (p) => {
|
||||
setPolicyModal(false);
|
||||
setCurrentPolicy(undefined);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<PeerSSHPolicyModal
|
||||
open={sshPolicyModal}
|
||||
onOpenChange={setSshPolicyModal}
|
||||
peer={peer}
|
||||
/>
|
||||
</Modal>
|
||||
<PeerSSHPolicyModal
|
||||
open={sshPolicyModal}
|
||||
onOpenChange={setSshPolicyModal}
|
||||
peer={peer}
|
||||
/>
|
||||
</PoliciesProvider>
|
||||
</PoliciesProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user