Compare commits

...

19 Commits

Author SHA1 Message Date
Misha Bragin
d2febbf27b Fix version comparison (#544)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 14:13:27 +01:00
Misha Bragin
615b4487ad Point to the right upgrade doc (#543)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 12:23:56 +01:00
Misha Bragin
a7c7800916 Add invite notification count badge (#542) 2026-01-27 10:44:39 +01:00
Eduard Gert
3d51e0893e Update announcement (#538)
* Update announcement

* Fix repeated fetches
2026-01-27 09:33:43 +01:00
Misha Bragin
d7d44b5817 Adjust Invites API (#541)
* Add API adjustments

* Invite_link renamed to invite_token
2026-01-26 19:25:56 +01:00
Misha Bragin
f67f39b68b Local user invites (#539) 2026-01-25 21:40:49 +01:00
dependabot[bot]
d2bc7a1f57 Bump lodash from 4.17.21 to 4.17.23 (#537)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-23 13:28:28 +01:00
Eduard Gert
818ba5daa4 Allow wildcard dns zone records (#536)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-20 17:32:14 +01:00
Ali Amer
3a30f76629 Add Frontend Support for Peer Debug Bundle Trigger and History (#485)
* implement debug ui

* update job ui

* Add type cell, show tooltip if peer is offline, add copy to clipboard for upload key, show error reason in tooltip

* update job event description

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-01-20 17:12:33 +01:00
Misha Bragin
34dc21c89d Add password change (embedded Idp) (#535) 2026-01-20 15:00:14 +01:00
Eduard Gert
2e37703622 Update CONTRIBUTOR_LICENSE_AGREEMENT.md (#534) 2026-01-19 14:55:04 +01:00
Eduard Gert
8aec338c43 Fix dns doc link (#533)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-19 10:01:55 +01:00
Viktor Liu
f4f0c240fd Bump wasm to v0.63.0 (#531) 2026-01-19 09:49:26 +01:00
Viktor Liu
04e22a3c7e Enable SSH for Windows and Android peers (#532)
* Enable SSH for Windows and Android peers, hide update badge for temporary peers

* Fix RDP to use tcp protocol instead of netbird-ssh
2026-01-19 09:49:08 +01:00
Eduard Gert
54ef076303 Fix config vars (#529)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-16 19:59:42 +01:00
Eduard Gert
92676b6c38 Add DNS zones (#528)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-16 17:33:16 +01:00
Eduard Gert
3affa8908f Redirect /setup to /peers if no setup is required (#526)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Redirect /setup to /peers if not setup is required

* Fix bad state while redirect

* Prevent redirect to /setup if already on /setup

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

* Add initial identity provider view

* Add IdP logos

* Add IdP id to user

* Add IdP logo to user obj

* Fix okta icon

* Return callback URL when creating an IdP

* Create user for self-hosted

* Clear up password from the state

* Show IdPs and create user when enabled

* Fetch IdPs only when embedded idp is enabled

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

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

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

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

* Update src/modules/settings/IdentityProvidersTab.tsx

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

* Update src/modules/settings/IdentityProviderModal.tsx

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

* Update src/modules/settings/IdentityProvidersTab.tsx

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

* Update src/modules/settings/IdentityProviderModal.tsx

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

* Rename IdentityProvider to SSOIdentityProvider

* Fix build and extract icons

* Fix initial onboarding

* Add icons

* Move name to the top

* Fix setup wizard background color

* Update instance setup ui

* Update instance setup ui

* Use input component

* Move idp label and icons

* Fix setup wizard width

* Add authentik and keycloak

* Add idp hints

* Handle idp permissions

* Consider selfhosted instances when checking if netbird is hosted

* Update redirect

* Add max retries to redirect

* Require new secret when clientid changed

* Add callback URL on the idp creation step

* Add idp activity events

---------

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

View File

@@ -54,8 +54,19 @@ jobs:
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "version=development" >> $GITHUB_OUTPUT
fi
- name: Build
run: npm run build
env:
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2

View File

@@ -1,7 +1,7 @@
## Contributor License Agreement
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany,
submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany,
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance

12
announcements.json Normal file
View File

@@ -0,0 +1,12 @@
[
{
"tag": "New",
"text": "Custom DNS Zones for Private Network Resolution",
"link": "https://netbird.io/knowledge-hub/custom-dns-zones",
"linkText": "Read Release Article",
"variant": "important",
"isExternal": true,
"closeable": true,
"isCloudOnly": false
}
]

View File

@@ -15,4 +15,4 @@
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
"wasmPath": "$NETBIRD_WASM_PATH"
}
}

View File

@@ -7,6 +7,8 @@ const nextConfig = {
reactStrictMode: false,
env: {
APP_ENV: process.env.APP_ENV || "production",
NEXT_PUBLIC_DASHBOARD_VERSION:
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development",
},
};

39
package-lock.json generated
View File

@@ -56,9 +56,10 @@
"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",
"lodash": "^4.17.23",
"lucide-react": "^0.539.0",
"next": "^14.2.35",
"next-themes": "^0.2.1",
@@ -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",
@@ -7242,9 +7253,10 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
@@ -8561,7 +8573,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",

View File

@@ -61,9 +61,10 @@
"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",
"lodash": "^4.17.23",
"lucide-react": "^0.539.0",
"next": "^14.2.35",
"next-themes": "^0.2.1",

View File

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

View File

@@ -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>

View File

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

View 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>
);
}

View File

@@ -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}

View File

@@ -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,6 +40,7 @@ import {
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
RadioTowerIcon,
TimerResetIcon,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
@@ -61,12 +61,13 @@ 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 { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection";
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 +81,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 +101,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 +137,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 +148,6 @@ const PeerGeneralInformation = () => {
*/
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
selectedGroups,
loginExpiration,
inactivityExpiration,
]);
const updatePeer = async (newName?: string) => {
@@ -170,8 +157,6 @@ const PeerGeneralInformation = () => {
if (permission.peers.update) {
const updateRequest = update({
name: newName ?? name,
loginExpiration,
inactivityExpiration,
});
batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest];
} else {
@@ -184,11 +169,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 +265,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 />
@@ -382,6 +329,13 @@ const PeerOverviewTabs = () => {
Accessible Peers
</TabsTrigger>
)}
{peer?.id && permission.peers.delete && (
<TabsTrigger value={"peer-job"}>
<RadioTowerIcon size={16} />
Remote Jobs
</TabsTrigger>
)}
</TabsList>
{permission.routes.read && (
@@ -395,6 +349,11 @@ const PeerOverviewTabs = () => {
<AccessiblePeersSection peerID={peer.id} />
</TabsContent>
)}
{peer.id && permission.peers.delete && (
<TabsContent value={"peer-job"} className={"pb-8"}>
<PeerRemoteJobsSection peerID={peer.id} />
</TabsContent>
)}
</Tabs>
);
};
@@ -582,9 +541,9 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
peer.connected
? "just now"
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(peer.last_seen) +
")"
" (" +
dayjs().to(peer.last_seen) +
")"
}
/>

View File

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

View File

@@ -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) {

View File

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

321
src/app/invite/page.tsx Normal file
View File

@@ -0,0 +1,321 @@
"use client";
import Button from "@components/Button";
import { Input } from "@components/Input";
import Paragraph from "@components/Paragraph";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { acceptInvite, fetchInviteInfo } from "@utils/unauthenticatedApi";
import {
AlertCircle,
CheckCircle2,
Clock,
KeyRound,
Mail,
User2,
} from "lucide-react";
import dayjs from "dayjs";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { UserInviteInfo } from "@/interfaces/User";
export default function InviteAcceptPage() {
return (
<Suspense fallback={<FullScreenLoading />}>
<InviteAcceptContent />
</Suspense>
);
}
function InviteAcceptContent() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams?.get("token");
const [loading, setLoading] = useState(true);
const [inviteInfo, setInviteInfo] = useState<UserInviteInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [isRateLimited, setIsRateLimited] = useState(false);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!token) {
setError("No invite token provided");
setLoading(false);
return;
}
fetchInviteInfo(token)
.then((info) => {
setInviteInfo(info);
setLoading(false);
})
.catch((err) => {
if (err.code === 429) {
setError("Too many attempts. Please wait a moment and try again.");
setIsRateLimited(true);
} else {
setError(err.message || "Invalid or expired invite link");
setIsRateLimited(false);
}
setLoading(false);
});
}, [token]);
const passwordsMatch = password === confirmPassword;
const hasMinLength = password.length >= 8;
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const passwordValid = hasMinLength && hasUppercase && hasLowercase && hasNumber && hasSpecialChar;
const canSubmit = passwordValid && passwordsMatch && !submitting;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit || !token) return;
setSubmitting(true);
setError(null);
try {
await acceptInvite(token, password);
setSuccess(true);
} catch (err: any) {
setError(err.message || "Failed to accept invite");
} finally {
setSubmitting(false);
}
};
const isExpired = useMemo(() => {
if (!inviteInfo) return false;
return new Date(inviteInfo.expires_at) < new Date();
}, [inviteInfo]);
if (loading) {
return <FullScreenLoading />;
}
if (error && !inviteInfo) {
if (isRateLimited) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
<Clock className="w-8 h-8 text-yellow-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Too Many Requests
</h1>
<Paragraph className="text-nb-gray-400 text-base">
You&apos;ve made too many requests. Please wait a moment and try
again.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => window.location.reload()}
>
Try Again
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Invalid Invite
</h1>
<Paragraph className="text-nb-gray-400 text-base">
This invite link is invalid or has expired. Please contact your
administrator to receive a new invitation.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-green-500/10 rounded-full flex items-center justify-center">
<CheckCircle2 className="w-8 h-8 text-green-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Account Created!
</h1>
<Paragraph className="text-nb-gray-400">
Your account has been created successfully. You can now log in with
your email and password.
</Paragraph>
<Button
variant="primary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
if (isExpired || !inviteInfo?.valid) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-yellow-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Invite Expired
</h1>
<Paragraph className="text-nb-gray-400">
This invite link has expired. Please contact your administrator to
receive a new invitation.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full">
<div className="mb-8 flex justify-center">
<NetBirdIcon size={48} />
</div>
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold text-white mb-2">
Welcome to NetBird
</h1>
<p className="dark:text-nb-gray-400 text-nb-gray-500 text-base">
You&apos;ve been invited by <span className="dark:text-white text-nb-gray-900 font-medium">{inviteInfo.invited_by}</span> to join the network. Set your password to complete your account setup.
</p>
</div>
<div className="bg-nb-gray-930 border border-nb-gray-900 rounded-lg p-6 mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-nb-gray-900 rounded-full flex items-center justify-center">
<User2 className="w-5 h-5 text-nb-gray-400" />
</div>
<div>
<div className="text-white font-medium">{inviteInfo.name}</div>
<div className="text-nb-gray-400 text-sm flex items-center gap-1">
<Mail className="w-3 h-3" />
{inviteInfo.email}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
customPrefix={
<KeyRound size={16} className="text-nb-gray-400" />
}
/>
{password && (
<div className="mt-2 space-y-1">
<PasswordRule met={hasMinLength} text="At least 8 characters" />
<PasswordRule met={hasUppercase} text="One uppercase letter" />
<PasswordRule met={hasLowercase} text="One lowercase letter" />
<PasswordRule met={hasNumber} text="One number" />
<PasswordRule met={hasSpecialChar} text="One special character (!@#$%^&*)" />
</div>
)}
</div>
<div>
<Input
type="password"
placeholder="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
customPrefix={
<KeyRound size={16} className="text-nb-gray-400" />
}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-500 mt-1">
Passwords do not match
</p>
)}
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-3">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<Button
type="submit"
variant="primary"
className="w-full"
disabled={!canSubmit}
>
{submitting ? "Creating Account..." : "Create Account"}
</Button>
</form>
</div>
<p className="text-center text-xs text-nb-gray-500">
Invite expires on {dayjs(inviteInfo.expires_at).format("D MMMM, YYYY [at] h:mm A")}
</p>
</div>
</div>
);
}
function PasswordRule({ met, text }: { met: boolean; text: string }) {
return (
<div className="flex items-center gap-2 text-xs">
{met ? (
<CheckCircle2 className="w-3 h-3 text-green-500" />
) : (
<AlertCircle className="w-3 h-3 text-nb-gray-500" />
)}
<span className={met ? "text-green-500" : "text-nb-gray-500"}>{text}</span>
</div>
);
}

View File

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

View File

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

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

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

22
src/app/setup/page.tsx Normal file
View 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 />
);
}

View File

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

View File

@@ -0,0 +1,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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
);
}

View File

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

View File

@@ -7,7 +7,6 @@ import {
} from "@axa-fr/react-oidc";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRedirect } from "@hooks/useRedirect";
import loadConfig, { buildExtras } from "@utils/config";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
@@ -75,8 +74,7 @@ export default function OIDCProvider({ children }: Props) {
const withCustomHistory = () => {
return {
replaceState: (url: any) => {
router.replace(url);
window.dispatchEvent(new Event("popstate"));
window?.location?.replace(url);
},
};
};
@@ -105,16 +103,19 @@ 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.
// Or the invite acceptance page for new users.
if (path === "/install" || path === "/setup" || path?.startsWith("/invite"))
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 +126,3 @@ export default function OIDCProvider({ children }: Props) {
<FullScreenLoading />
);
}
const CallBackSuccess = () => {
const params = useSearchParams();
const errorParam = params.get("error");
const currentPath = usePathname();
useRedirect(currentPath, true, !errorParam);
return <FullScreenLoading />;
};

View File

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

View File

@@ -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<

View File

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

View File

@@ -2,8 +2,9 @@ import FullTooltip from "@components/FullTooltip";
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { cva, VariantProps } from "class-variance-authority";
import { AlertCircle } from "lucide-react";
import { AlertCircle, Eye, EyeOff } from "lucide-react";
import * as React from "react";
import { useState } from "react";
type InputVariants = VariantProps<typeof inputVariants>;
@@ -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

View File

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

View File

@@ -0,0 +1,36 @@
import { cn } from "@utils/helpers";
import * as React from "react";
export const TooltipListItem = ({
icon,
label,
value,
className,
labelClassName,
}: {
icon?: React.ReactNode;
label: string;
value: string | React.ReactNode;
className?: string;
labelClassName?: string;
}) => {
return (
<div
className={cn(
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
className,
)}
>
<div
className={cn(
"flex items-center gap-2 text-nb-gray-100 font-medium",
labelClassName,
)}
>
{icon}
{label}
</div>
<div className={"text-nb-gray-300"}>{value}</div>
</div>
);
};

View File

@@ -0,0 +1,148 @@
"use client";
import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers";
import { ArrowUpCircle } from "lucide-react";
import * as React from "react";
import Skeleton from "react-loading-skeleton";
import useFetchApi from "@utils/api";
import { isNetBirdHosted } from "@utils/netbird";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { VersionInfo as VersionInfoType } from "@/interfaces/Instance";
function formatVersion(version: string): string {
if (!version) return "";
// Add "v" prefix if version starts with a number
if (/^\d/.test(version)) return `v${version}`;
return version;
}
function compareVersions(current: string, latest: string): boolean {
// Returns true if latest is newer than current
if (!current || !latest) return false;
if (current === "development") return false;
// Strip "v" prefix if present
const normalizedCurrent = current.replace(/^v/, "");
const normalizedLatest = latest.replace(/^v/, "");
const currentParts = normalizedCurrent
.split(".")
.map((p) => parseInt(p, 10) || 0);
const latestParts = normalizedLatest
.split(".")
.map((p) => parseInt(p, 10) || 0);
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const c = currentParts[i] || 0;
const l = latestParts[i] || 0;
if (l > c) return true;
if (l < c) return false;
}
return false;
}
export const NavigationVersionInfo = () => {
const { isNavigationCollapsed, mobileNavOpen } = useApplicationContext();
// Only show for self-hosted, not cloud
if (isNetBirdHosted()) return null;
return (
<div
className={cn(
"px-4 py-4 animate-fade-in",
isNavigationCollapsed &&
!mobileNavOpen &&
"hidden md:group-hover/navigation:block",
)}
>
<NavigationVersionInfoContent />
</div>
);
};
const NavigationVersionInfoContent = () => {
const { data: versionInfo, isLoading } = useFetchApi<VersionInfoType>(
"/instance/version",
true, // ignore errors
false, // don't revalidate on focus
);
const dashboardVersion =
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development";
if (isLoading)
return <Skeleton height={80} className={"rounded-lg opacity-60"} />;
if (!versionInfo) return null;
// Compare versions to detect updates (returns false for "development" versions)
const managementUpdateAvailable = compareVersions(
versionInfo.management_current_version,
versionInfo.management_available_version,
);
const dashboardUpdateAvailable = compareVersions(
dashboardVersion,
versionInfo.dashboard_available_version,
);
const hasUpdate = managementUpdateAvailable || dashboardUpdateAvailable;
return (
<div
className={cn(
"w-full rounded-md text-xs flex flex-col gap-2 whitespace-normal border text-left",
"bg-nb-gray-900/20 py-3 px-3 border-nb-gray-800/30",
)}
>
<div className="flex flex-col gap-1 text-nb-gray-400">
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.management_available_version)}
</span>
}
side="top"
className="w-full"
>
<div className="flex items-center justify-between w-full cursor-default">
<span>Management</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(versionInfo.management_current_version)}
</span>
</div>
</FullTooltip>
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
}
side="top"
className="w-full"
>
<div className="flex items-center justify-between w-full cursor-default">
<span>Dashboard</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(dashboardVersion)}
</span>
</div>
</FullTooltip>
</div>
{hasUpdate && (
<a
href="https://docs.netbird.io/selfhosted/maintenance/upgrade"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 text-white font-medium bg-netbird hover:bg-netbird-500 transition-colors rounded-md py-1.5 px-2 mt-1"
>
<ArrowUpCircle size={12} />
<span>Update available</span>
</a>
)}
</div>
);
};
export default NavigationVersionInfo;

View File

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

View File

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

View File

@@ -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,
)}

View File

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

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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",
)}

View File

@@ -11,7 +11,7 @@ import {
} from "@components/DropdownMenu";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { UserAvatar } from "@components/ui/UserAvatar";
import { LogOutIcon, User2 } from "lucide-react";
import { KeyRound, LogOutIcon, User2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
@@ -19,9 +19,13 @@ import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import useOSDetection from "@/hooks/useOperatingSystem";
import { ChangePasswordModalContent } from "@/modules/users/ChangePasswordModal";
import { isNetBirdHosted } from "@utils/netbird";
import { Modal } from "@components/modal/Modal";
export default function UserDropdown() {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [changePasswordModal, setChangePasswordModal] = useState(false);
const { user } = useApplicationContext();
const { loggedInUser, logout } = useLoggedInUser();
const { isRestricted, permission } = usePermissions();
@@ -31,17 +35,28 @@ export default function UserDropdown() {
useHotkeys("shift+mod+l", () => logout(), []);
return (
<DropdownMenu
modal={false}
open={dropdownOpen}
onOpenChange={setDropdownOpen}
>
<>
<Modal
open={changePasswordModal}
onOpenChange={setChangePasswordModal}
key={changePasswordModal ? 1 : 0}
>
<ChangePasswordModalContent
userId={loggedInUser?.id}
onSuccess={() => setChangePasswordModal(false)}
/>
</Modal>
<DropdownMenu
modal={false}
open={dropdownOpen}
onOpenChange={setDropdownOpen}
>
<DropdownMenuTrigger>
<UserAvatar size={"medium"} />
</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}
@@ -72,6 +87,20 @@ export default function UserDropdown() {
/>
)}
{!isNetBirdHosted() && loggedInUser?.idp_id === "local" && (
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
setChangePasswordModal(true);
}}
>
<div className={"flex gap-3 items-center"}>
<KeyRound size={14} />
Change Password
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={logout}>
<div className={"flex gap-3 items-center"}>
<LogOutIcon size={14} />
@@ -81,6 +110,7 @@ export default function UserDropdown() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@@ -1,21 +1,26 @@
import { AnnouncementVariant } from "@components/ui/AnnouncementBanner";
import { useLocalStorage } from "@hooks/useLocalStorage";
import md5 from "crypto-js/md5";
import React, { useEffect, useState } from "react";
import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { isNetBirdHosted } from "@utils/netbird";
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"
isExternal: true,
closeable: true,
isCloudOnly: false,
},
];
const ANNOUNCEMENTS_URL =
"https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json";
const STORAGE_KEY = "netbird-announcements";
const CACHE_DURATION_MS = 30 * 60 * 1000;
const BANNER_HEIGHT = 40;
interface AnnouncementStore {
timestamp: number;
announcements: Announcement[];
closedAnnouncements: string[];
}
export interface Announcement extends AnnouncementVariant {
tag: string;
@@ -36,7 +41,7 @@ type Props = {
children: React.ReactNode;
};
const AnnouncementContext = React.createContext(
const AnnouncementContext = createContext(
{} as {
bannerHeight: number;
announcements?: AnnouncementInfo[];
@@ -47,59 +52,99 @@ const AnnouncementContext = React.createContext(
},
);
const bannerHeight = 40;
const getAnnouncements = async (): Promise<AnnouncementInfo[]> => {
try {
let stored: AnnouncementStore | null = null;
try {
const data = localStorage.getItem(STORAGE_KEY);
stored = data ? JSON.parse(data) : null;
} catch {}
const now = Date.now();
let raw: Announcement[];
if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
raw = stored.announcements;
} else {
const response = await fetch(ANNOUNCEMENTS_URL);
if (!response.ok) return [];
raw = await response.json();
}
const isCloud = isNetBirdHosted();
const filtered = raw.filter((a) => !a.isCloudOnly || isCloud);
const hashes = new Set(filtered.map((a) => md5(a.text).toString()));
const closed = (stored?.closedAnnouncements ?? []).filter((h) =>
hashes.has(h),
);
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
timestamp: now,
announcements: raw,
closedAnnouncements: closed,
}),
);
} catch {}
return filtered.map((a) => {
const hash = md5(a.text).toString();
return { ...a, hash, isOpen: !closed.includes(hash) };
});
} catch {
return [];
}
};
const saveAnnouncements = (closedAnnouncements: string[]) => {
try {
const data = localStorage.getItem(STORAGE_KEY);
const stored: AnnouncementStore | null = data ? JSON.parse(data) : null;
if (stored) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ ...stored, closedAnnouncements }),
);
}
} catch {}
};
export default function AnnouncementProvider({ children }: Readonly<Props>) {
const [height, setHeight] = useState(0);
const [closedAnnouncements, setClosedAnnouncements] = useLocalStorage<
string[]
>("netbird-closed-announcements", []);
const [announcements, setAnnouncements] = useState<AnnouncementInfo[]>();
const { isRestricted } = usePermissions();
const fetchingRef = useRef(false);
useEffect(() => {
if (announcements && announcements.length > 0) return;
if (isRestricted) return;
const initial = initialAnnouncements.map((announcement) => {
const hash = md5(announcement.text).toString();
const isOpen = !closedAnnouncements.some((h) => h === hash);
return {
...announcement,
hash,
isOpen,
} as AnnouncementInfo;
});
if (initial.length > 0) {
setAnnouncements(initial);
}
}, [closedAnnouncements, announcements]);
if (announcements !== undefined || isRestricted || fetchingRef.current)
return;
fetchingRef.current = true;
getAnnouncements()
.then((a) => setAnnouncements(a))
.finally(() => (fetchingRef.current = false));
}, [announcements, isRestricted]);
const closeAnnouncement = (hash: string) => {
setClosedAnnouncements([...closedAnnouncements, hash]);
setAnnouncements(() => {
return announcements?.map((a) => {
if (a.hash === hash) {
return { ...a, isOpen: false };
}
return a;
});
});
if (!announcements) return;
const updated = announcements.map((a) =>
a.hash === hash ? { ...a, isOpen: false } : a,
);
const closedAnnouncements = updated
.filter((a) => !a.isOpen)
.map((a) => a.hash);
saveAnnouncements(closedAnnouncements);
setAnnouncements(updated);
};
useEffect(() => {
const isAnnouncementOpen = announcements?.some((a) => a.isOpen);
if (isAnnouncementOpen) {
setHeight(bannerHeight);
} else {
setHeight(0);
}
}, [announcements]);
const bannerHeight = announcements?.some((a) => a.isOpen) ? BANNER_HEIGHT : 0;
return (
<AnnouncementContext.Provider
value={{
bannerHeight: height,
bannerHeight,
announcements,
closeAnnouncement,
setAnnouncements,
@@ -110,6 +155,4 @@ export default function AnnouncementProvider({ children }: Readonly<Props>) {
);
}
export const useAnnouncement = () => {
return React.useContext(AnnouncementContext);
};
export const useAnnouncement = () => useContext(AnnouncementContext);

View File

@@ -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"}

View File

@@ -0,0 +1,93 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import React, { createContext, useContext, useEffect, useState } from "react";
import FullScreenLoading from "@/components/ui/FullScreenLoading";
import { fetchInstanceStatus } from "@/utils/unauthenticatedApi";
import { isNetBirdHosted } from "@utils/netbird";
interface InstanceSetupContextType {
setupRequired: boolean;
loading: boolean;
}
const InstanceSetupContext = createContext<InstanceSetupContextType>({
setupRequired: false,
loading: true,
});
export const useInstanceSetup = () => useContext(InstanceSetupContext);
// Check if we're in an OIDC callback flow (hash-based routing)
const isOIDCCallback = () => {
if (typeof window === "undefined") return false;
const hash = window.location.hash;
return hash.startsWith("#callback") || hash.startsWith("#silent-callback");
};
export default function InstanceSetupProvider({
children,
}: {
children: React.ReactNode;
}) {
const [setupRequired, setSetupRequired] = useState(false);
const [loading, setLoading] = useState(true);
const router = useRouter();
const pathname = usePathname();
// Routes that don't need setup check
const bypassRoutes = ["/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>
);
}

View File

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

View File

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

25
src/interfaces/DNS.ts Normal file
View 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";

View File

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

View File

@@ -0,0 +1,25 @@
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;
}
export interface VersionInfo {
management_current_version: string;
management_available_version: string;
dashboard_available_version: string;
}

23
src/interfaces/Job.ts Normal file
View File

@@ -0,0 +1,23 @@
export interface Job {
id: string;
triggered_by: string;
completed_at: Date | null;
created_at: Date;
failed_reason: string | null;
workload: Workload;
status: "pending" | "succeeded" | "failed";
}
export interface Workload {
type: "bundle";
parameters: BundleJobParameters;
result: string | null;
}
// Parameters for bundle job
export interface BundleJobParameters {
anonymize: boolean;
bundle_for: boolean;
bundle_for_time: number;
log_file_count: number;
}

View File

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

View File

@@ -13,6 +13,53 @@ export interface User {
pending_approval?: boolean;
last_login?: Date;
permissions: Permissions;
password?: string;
idp_id?: string;
}
export interface UserInviteCreateRequest {
email: string;
name: string;
role: string;
auto_groups: string[];
expires_in?: number;
}
export interface UserInvite {
id: string;
email: string;
name: string;
role: string;
auto_groups: string[];
expires_at: string;
created_at: string;
expired: boolean;
invite_token?: string;
}
export interface UserInviteInfo {
email: string;
name: string;
expires_at: string;
valid: boolean;
invited_by: string;
}
export interface UserInviteAcceptRequest {
password: string;
}
export interface UserInviteAcceptResponse {
success: boolean;
}
export interface UserInviteRegenerateRequest {
expires_in?: number;
}
export interface UserInviteRegenerateResponse {
invite_token: string;
invite_expires_at: string;
}
export enum Role {

View File

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

View File

@@ -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>

View File

@@ -14,6 +14,7 @@ import SettingsIcon from "@/assets/icons/SettingsIcon";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import TeamIcon from "@/assets/icons/TeamIcon";
import SidebarItem from "@/components/SidebarItem";
import { NavigationVersionInfo } from "@/components/VersionInfo";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -143,6 +144,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
@@ -195,6 +202,7 @@ export default function Navigation({
/>
</SidebarItemGroup>
</div>
<NavigationVersionInfo />
</div>
</ScrollArea>
</div>

View File

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

View File

@@ -277,6 +277,50 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "user.password.change")
return (
<div className={"inline"}>
Password was changed for user <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
/**
* User Invite Link
*/
if (event.activity_code == "user.invite.link.create")
return (
<div className={"inline"}>
Invite link was created for <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "user.invite.link.accept")
return (
<div className={"inline"}>
Invite link was accepted by <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "user.invite.link.regenerate")
return (
<div className={"inline"}>
Invite link was regenerated for <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "user.invite.link.delete")
return (
<div className={"inline"}>
Invite link was deleted for <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
/**
* Service User
*/
@@ -693,6 +737,16 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Jobs
*/
if (event.activity_code == "peer.job.create")
return (<div className={"inline"}>
Remote job <Value>{m.job_type}</Value> created for peer <Value>{m.for_peer_name}</Value>
</div>
)
if (event.activity_code == "account.settings.extra.flow.group.remove")
return (
<div className={"inline"}>
@@ -707,6 +761,31 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Identity Provider
*/
if (event.activity_code == "identityprovider.create")
return (
<div className={"inline"}>
Identity provider <Value>{m.name}</Value> was created
</div>
);
if (event.activity_code == "identityprovider.update")
return (
<div className={"inline"}>
Identity provider <Value>{m.name}</Value> was updated
</div>
);
if (event.activity_code == "identityprovider.delete")
return (
<div className={"inline"}>
Identity provider <Value>{m.name}</Value> was deleted
</div>
);
return (
<div className={"flex gap-2.5 items-center"}>
<span className={"mb-[1px]"}>{event.activity}</span>

View File

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

View File

@@ -33,6 +33,7 @@ const ACTION_COLOR_MAPPING: Record<string, ActionStatus> = {
rename: ActionStatus.INFO,
unblock: ActionStatus.INFO,
login: ActionStatus.INFO,
change: ActionStatus.INFO,
};
export function getColorFromCode(code: string): string {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -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>[] = [
{

View File

@@ -0,0 +1,361 @@
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 "";
if (domain === "*") return "";
const valid = validator.isValidDomain(domain, {
allowWildcard: true,
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, wildcard or leave empty to use the primary
domain.
</HelpText>
<div className={"flex w-full"}>
<Input
autoFocus={true}
placeholder={"E.g., dev, * or 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`;
};

View 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>
);
}

View 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);

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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);

View 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>
);
};

View 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>
);
};

View 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}
/>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

Some files were not shown because too many files have changed in this diff Show More