Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61e11d3740 | ||
|
|
c8e3b50f1b | ||
|
|
25be69e7bb | ||
|
|
43e5d5cf53 | ||
|
|
18819d6fdf | ||
|
|
158804c1ac |
347
package-lock.json
generated
347
package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.4",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
@@ -921,6 +922,352 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.4.tgz",
|
||||
"integrity": "sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.3",
|
||||
"@radix-ui/react-popper": "1.2.1",
|
||||
"@radix-ui/react-portal": "1.1.3",
|
||||
"@radix-ui/react-presence": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
|
||||
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
|
||||
"integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
|
||||
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz",
|
||||
"integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
|
||||
"integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0",
|
||||
"@radix-ui/react-use-rect": "1.1.0",
|
||||
"@radix-ui/react-use-size": "1.1.0",
|
||||
"@radix-ui/rect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz",
|
||||
"integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
|
||||
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
|
||||
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
||||
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz",
|
||||
"integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-escape-keydown": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
|
||||
"integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
|
||||
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz",
|
||||
"integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz",
|
||||
"integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",
|
||||
"integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.4",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
|
||||
@@ -14,7 +14,6 @@ import PeersProvider from "@/contexts/PeersProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { NetworkRoutesDeprecationInfo } from "@/modules/networks/misc/NetworkRoutesDeprecationInfo";
|
||||
import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
|
||||
|
||||
const NetworkRoutesTable = lazy(
|
||||
@@ -40,9 +39,7 @@ export default function NetworkRoutes() {
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>
|
||||
Network Routes <NetworkRoutesDeprecationInfo size={18} />
|
||||
</h1>
|
||||
<h1 ref={headingRef}>Network Routes</h1>
|
||||
<Paragraph>
|
||||
Network routes allow you to access other networks like LANs and
|
||||
VPCs without installing NetBird on every resource.
|
||||
|
||||
@@ -31,12 +31,15 @@ export default function Networks() {
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Networks</h1>
|
||||
<Paragraph>
|
||||
Networks allow you to access other resources like LANs and VPCs
|
||||
without installing NetBird on every device.
|
||||
Networks allow you to access internal resources in LANs and VPCs without
|
||||
installing NetBird on every machine.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink href={"https://docs.netbird.io/how-to/networks"} target={"_blank"}>
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Networks
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
|
||||
@@ -23,13 +23,13 @@ import Separator from "@components/Separator";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import { IconCloudLock, IconInfoCircle } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
Barcode,
|
||||
Cpu,
|
||||
FlagIcon,
|
||||
Globe,
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
TerminalSquare,
|
||||
TimerResetIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toASCII } from "punycode";
|
||||
@@ -55,11 +56,11 @@ import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
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";
|
||||
|
||||
export default function PeerPage() {
|
||||
@@ -69,9 +70,16 @@ export default function PeerPage() {
|
||||
|
||||
useRedirect("/peers", false, !peerId);
|
||||
|
||||
const peerKey = useMemo(() => {
|
||||
let id = peer?.id ?? "";
|
||||
let ssh = peer?.ssh_enabled ? "1" : "0";
|
||||
let expiration = peer?.login_expiration_enabled ? "1" : "0";
|
||||
return `${id}-${ssh}-${expiration}`;
|
||||
}, [peer]);
|
||||
|
||||
return peer && !isLoading ? (
|
||||
<PeerProvider peer={peer} key={peerId}>
|
||||
<PeerOverview />
|
||||
<PeerOverview key={peerKey} />
|
||||
</PeerProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
@@ -88,20 +96,15 @@ function PeerOverview() {
|
||||
const [loginExpiration, setLoginExpiration] = useState(
|
||||
peer.login_expiration_enabled,
|
||||
);
|
||||
const [inactivityExpiration, setInactivityExpiration] = useState(
|
||||
peer.inactivity_expiration_enabled,
|
||||
);
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
initial: peerGroups,
|
||||
peer,
|
||||
});
|
||||
|
||||
/**
|
||||
* Check the operating system of the peer, if it is linux, then show the routes table, otherwise hide it.
|
||||
*/
|
||||
const isLinux = useMemo(() => {
|
||||
const operatingSystem = getOperatingSystem(peer.os);
|
||||
return operatingSystem == OperatingSystem.LINUX;
|
||||
}, [peer.os]);
|
||||
|
||||
/**
|
||||
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
|
||||
*/
|
||||
@@ -110,10 +113,16 @@ function PeerOverview() {
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
|
||||
const updatePeer = async () => {
|
||||
const updateRequest = update(name, ssh, loginExpiration);
|
||||
const updateRequest = update({
|
||||
name,
|
||||
ssh,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
});
|
||||
const groupCalls = getAllGroupCalls();
|
||||
const batchCall = groupCalls
|
||||
? [...groupCalls, updateRequest]
|
||||
@@ -124,13 +133,19 @@ function PeerOverview() {
|
||||
promise: Promise.all(batchCall).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
mutate("/groups");
|
||||
updateHasChangedRef([name, ssh, selectedGroups, loginExpiration]);
|
||||
updateHasChangedRef([
|
||||
name,
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
}),
|
||||
loadingMessage: "Saving the peer...",
|
||||
});
|
||||
};
|
||||
|
||||
const { isUser } = useLoggedInUser();
|
||||
const { isUser, isOwnerOrAdmin } = useLoggedInUser();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -212,53 +227,43 @@ function PeerOverview() {
|
||||
<div className={"flex gap-10 w-full mt-5 max-w-6xl"}>
|
||||
<PeerInformationCard peer={peer} />
|
||||
|
||||
<div className={"flex flex-col gap-6 w-1/2"}>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-center !text-nb-gray-300 text-xs"
|
||||
}
|
||||
>
|
||||
{!peer.user_id ? (
|
||||
<>
|
||||
<>
|
||||
<IconInfoCircle size={14} />
|
||||
<span>
|
||||
Login expiration is disabled for all peers added
|
||||
with an setup-key.
|
||||
</span>
|
||||
</>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LockIcon size={14} />
|
||||
<span>
|
||||
{`You don't have the required permissions to update this
|
||||
setting.`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className={"w-full block"}
|
||||
disabled={!!peer.user_id && !isUser}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
disabled={!peer.user_id || isUser}
|
||||
<div className={"flex flex-col gap-6 w-1/2 transition-all"}>
|
||||
<div>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
value={loginExpiration}
|
||||
onChange={setLoginExpiration}
|
||||
label={
|
||||
<>
|
||||
<IconCloudLock size={16} />
|
||||
Login Expiration
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Enable to require SSO login peers to re-authenticate when their login expires."
|
||||
}
|
||||
icon={<TimerResetIcon size={16} />}
|
||||
onChange={(state) => {
|
||||
setLoginExpiration(state);
|
||||
!state && setInactivityExpiration(false);
|
||||
}}
|
||||
/>
|
||||
</FullTooltip>
|
||||
{isOwnerOrAdmin && !!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>
|
||||
|
||||
<FullTooltip
|
||||
content={
|
||||
<div
|
||||
@@ -316,7 +321,7 @@ function PeerOverview() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLinux && !isUser ? (
|
||||
{!isUser ? (
|
||||
<>
|
||||
<Separator />
|
||||
<PeerNetworkRoutesSection peer={peer} />
|
||||
@@ -334,7 +339,7 @@ function PeerOverview() {
|
||||
);
|
||||
}
|
||||
|
||||
function PeerInformationCard({ peer }: { peer: Peer }) {
|
||||
function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
const { isLoading, getRegionByPeer } = useCountries();
|
||||
|
||||
const countryText = useMemo(() => {
|
||||
@@ -370,14 +375,20 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
|
||||
|
||||
<Card.ListItem
|
||||
copy
|
||||
copyText={"Domain name"}
|
||||
copyText={"DNS label"}
|
||||
label={
|
||||
<>
|
||||
<Globe size={16} />
|
||||
Domain Name
|
||||
</>
|
||||
}
|
||||
className={
|
||||
peer?.extra_dns_labels && peer.extra_dns_labels.length > 0
|
||||
? "items-start"
|
||||
: ""
|
||||
}
|
||||
value={peer.dns_label}
|
||||
extraText={peer?.extra_dns_labels}
|
||||
/>
|
||||
|
||||
<Card.ListItem
|
||||
@@ -429,6 +440,19 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
|
||||
}
|
||||
value={peer.os}
|
||||
/>
|
||||
|
||||
{peer.serial_number && peer.serial_number !== "" && (
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Barcode size={16} />
|
||||
Serial Number
|
||||
</>
|
||||
}
|
||||
value={peer.serial_number}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
@@ -475,7 +499,7 @@ interface ModalProps {
|
||||
peer: Peer;
|
||||
initialName: string;
|
||||
}
|
||||
function EditNameModal({ onSuccess, peer, initialName }: ModalProps) {
|
||||
function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
|
||||
const [name, setName] = useState(initialName);
|
||||
|
||||
const isDisabled = useMemo(() => {
|
||||
|
||||
@@ -40,6 +40,7 @@ export default function UserPage() {
|
||||
const { data: users, isLoading } = useFetchApi<User[]>(
|
||||
`/users?service_user=${isServiceUser}`,
|
||||
);
|
||||
const { isOwnerOrAdmin } = useLoggedInUser();
|
||||
|
||||
const user = useMemo(() => {
|
||||
return users?.find((u) => u.id === userId);
|
||||
@@ -49,11 +50,15 @@ export default function UserPage() {
|
||||
|
||||
const userGroups = useGroupIdsToGroups(user?.auto_groups);
|
||||
|
||||
return !isLoading && user && userGroups !== undefined ? (
|
||||
<UserOverview user={user} initialGroups={userGroups} />
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
);
|
||||
if (!isOwnerOrAdmin && user && !isLoading) {
|
||||
return <UserOverview user={user} initialGroups={[]} />;
|
||||
}
|
||||
|
||||
if (isOwnerOrAdmin && user && !isLoading && userGroups) {
|
||||
return <UserOverview user={user} initialGroups={userGroups} />;
|
||||
}
|
||||
|
||||
return <FullScreenLoading />;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -195,7 +200,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<UserInformationCard user={user} />
|
||||
<div className={"flex flex-col gap-8 w-1/2 "}>
|
||||
{!user.is_service_user && (
|
||||
{!user.is_service_user && isOwnerOrAdmin && (
|
||||
<div>
|
||||
<Label>Auto-assigned groups</Label>
|
||||
<HelpText>
|
||||
@@ -316,6 +321,7 @@ function UserInformationCard({ user }: { user: User }) {
|
||||
<>
|
||||
{!user.is_current && user.role != Role.Owner && (
|
||||
<Card.ListItem
|
||||
tooltip={false}
|
||||
label={
|
||||
<>
|
||||
<Ban size={16} />
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function CircleIcon({
|
||||
size = 11,
|
||||
inactiveDot = "gray",
|
||||
className,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<span
|
||||
style={{ width: size + "px", height: size + "px" }}
|
||||
|
||||
39
src/assets/icons/EntraIcon.tsx
Normal file
39
src/assets/icons/EntraIcon.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function EntraIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
width="231"
|
||||
height="231"
|
||||
viewBox="0 0 231 231"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M48.7923 180.077C53.7717 183.183 62.0492 186.635 70.8015 186.635C78.771 186.635 86.1758 184.325 92.3102 180.385C92.3102 180.385 92.323 180.385 92.3358 180.373L115.5 165.896V218.167C111.83 218.167 108.134 217.166 104.925 215.164L48.7923 180.077Z"
|
||||
fill="#225086"
|
||||
/>
|
||||
<path
|
||||
d="M100.78 19.3398L4.53017 127.91C-2.90033 136.303 -0.962501 148.982 8.67533 155.001C8.67533 155.001 44.3007 177.267 48.7923 180.077C53.7717 183.183 62.0492 186.635 70.8015 186.635C78.771 186.635 86.1758 184.325 92.3102 180.385C92.3102 180.385 92.323 180.385 92.3358 180.373L115.5 165.896L59.4953 130.887L115.513 67.6958V12.8333C110.072 12.8333 104.63 15.0022 100.78 19.3398Z"
|
||||
fill="#66DDFF"
|
||||
/>
|
||||
<path
|
||||
d="M59.4953 130.887L60.1627 131.298L115.5 165.896H115.513V67.7087L115.5 67.6958L59.4953 130.887Z"
|
||||
fill="#CBF8FF"
|
||||
/>
|
||||
<path
|
||||
d="M222.325 155.001C231.963 148.982 233.9 136.303 226.47 127.91L163.317 56.672C158.222 54.2978 152.511 52.9375 146.467 52.9375C134.596 52.9375 123.983 58.058 116.925 66.1045L115.526 67.683L171.53 130.874L115.513 165.884V218.154C119.196 218.154 122.866 217.153 126.075 215.151L222.325 154.988V155.001Z"
|
||||
fill="#074793"
|
||||
/>
|
||||
<path
|
||||
d="M115.513 12.8333V67.6958L116.912 66.1173C123.97 58.0708 134.583 52.9503 146.454 52.9503C152.511 52.9503 158.209 54.3235 163.304 56.6848L130.207 19.3527C126.37 15.015 120.929 12.8462 115.5 12.8462L115.513 12.8333Z"
|
||||
fill="#0294E4"
|
||||
/>
|
||||
<path
|
||||
d="M171.518 130.887L115.513 67.7087V165.884L171.518 130.887Z"
|
||||
fill="#96BCC2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
31
src/assets/icons/GoogleIcon.tsx
Normal file
31
src/assets/icons/GoogleIcon.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function GoogleIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
<path d="M1 1h22v22H1z" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
36
src/assets/icons/JWTIcon.tsx
Normal file
36
src/assets/icons/JWTIcon.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function JWTIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
height="2500"
|
||||
viewBox=".4 .3 99.7 100"
|
||||
width="2500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<g fill="none">
|
||||
<path
|
||||
d="m57.8 27.2-.1-26.9h-15l.1 26.9 7.5 10.3zm-15 46.1v27h15v-27l-7.5-10.3z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path
|
||||
d="m57.8 73.3 15.8 21.8 12.1-8.8-15.8-21.8-12.1-3.9zm-15-46.1-15.9-21.8-12.1 8.8 15.8 21.8 12.2 3.9z"
|
||||
fill="#00f2e6"
|
||||
/>
|
||||
<path
|
||||
d="m30.6 36-25.6-8.3-4.6 14.2 25.6 8.4 12.1-4zm31.8 18.2 7.5 10.3 25.6 8.3 4.6-14.2-25.6-8.3z"
|
||||
fill="#00b9f1"
|
||||
/>
|
||||
<path
|
||||
d="m74.5 50.3 25.6-8.4-4.6-14.2-25.6 8.3-7.5 10.3zm-48.5 0-25.6 8.3 4.6 14.2 25.6-8.3 7.5-10.3z"
|
||||
fill="#d63aff"
|
||||
/>
|
||||
<path
|
||||
d="m30.6 64.5-15.8 21.8 12.1 8.8 15.9-21.8v-12.7zm39.3-28.5 15.8-21.8-12.1-8.8-15.8 21.8v12.7z"
|
||||
fill="#fb015b"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
src/assets/icons/OktaIcon.tsx
Normal file
18
src/assets/icons/OktaIcon.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ import React from "react";
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Card({ children, className, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
@@ -32,6 +33,7 @@ type CardListItemProps = {
|
||||
copy?: boolean;
|
||||
copyText?: string;
|
||||
tooltip?: boolean;
|
||||
extraText?: string[];
|
||||
};
|
||||
|
||||
function CardListItem({
|
||||
@@ -41,9 +43,8 @@ function CardListItem({
|
||||
copy = false,
|
||||
copyText,
|
||||
tooltip = true,
|
||||
extraText = [],
|
||||
}: CardListItemProps) {
|
||||
const [, copyToClipBoard] = useCopyToClipboard(value as string);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
@@ -52,29 +53,68 @@ function CardListItem({
|
||||
)}
|
||||
>
|
||||
<div className={"flex gap-2.5 items-center text-sm"}>{label}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
|
||||
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
|
||||
)}
|
||||
onClick={() =>
|
||||
copy &&
|
||||
copyToClipBoard(
|
||||
`${copyText ? copyText : label} has been copied to clipboard.`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{tooltip ? (
|
||||
<TextWithTooltip text={value as string} maxChars={40} />
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
{copy && <Copy size={13} className={"shrink-0"} />}
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<CardTextItem
|
||||
label={label}
|
||||
value={value}
|
||||
copy={copy}
|
||||
copyText={copyText}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
{extraText?.map((extraLabel, index) => (
|
||||
<CardTextItem
|
||||
key={index}
|
||||
label={label}
|
||||
value={extraLabel}
|
||||
copy={copy}
|
||||
copyText={copyText}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
type CardTextItemProps = {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
copy?: boolean;
|
||||
copyText?: string;
|
||||
tooltip?: boolean;
|
||||
};
|
||||
|
||||
const CardTextItem = ({
|
||||
label,
|
||||
value,
|
||||
copy = false,
|
||||
copyText,
|
||||
tooltip = true,
|
||||
}: CardTextItemProps) => {
|
||||
const [, copyToClipBoard] = useCopyToClipboard(value as string);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
|
||||
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
|
||||
)}
|
||||
onClick={() =>
|
||||
copy &&
|
||||
copyToClipBoard(
|
||||
`${copyText ? copyText : label} has been copied to clipboard.`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{tooltip ? (
|
||||
<TextWithTooltip text={value as string} maxChars={40} />
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
{copy && <Copy size={13} className={"shrink-0"} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Card.List = CardList;
|
||||
Card.ListItem = CardListItem;
|
||||
|
||||
|
||||
@@ -6,9 +6,18 @@ import useCopyToClipboard from "@/hooks/useCopyToClipboard";
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
message?: string;
|
||||
iconAlignment?: "left" | "right";
|
||||
className?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
};
|
||||
|
||||
export default function CopyToClipboardText({ children, message }: Props) {
|
||||
export default function CopyToClipboardText({
|
||||
children,
|
||||
message,
|
||||
iconAlignment = "right",
|
||||
className,
|
||||
alwaysShowIcon = false,
|
||||
}: Props) {
|
||||
const [wrapper, copyToClipboard, copied] = useCopyToClipboard();
|
||||
|
||||
return (
|
||||
@@ -16,6 +25,7 @@ export default function CopyToClipboardText({ children, message }: Props) {
|
||||
className={cn(
|
||||
"flex gap-2 items-center group cursor-pointer transition-all hover:underline underline-offset-4 decoration-dashed decoration-nb-gray-600",
|
||||
!copied && "hover:opacity-90",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -28,17 +38,21 @@ export default function CopyToClipboardText({ children, message }: Props) {
|
||||
|
||||
{copied ? (
|
||||
<CheckIcon
|
||||
className={
|
||||
"text-nb-gray-100 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
}
|
||||
size={12}
|
||||
className={cn(
|
||||
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
|
||||
iconAlignment === "left" ? "order-first" : "order-last",
|
||||
!alwaysShowIcon && "opacity-0",
|
||||
)}
|
||||
size={11}
|
||||
/>
|
||||
) : (
|
||||
<CopyIcon
|
||||
className={
|
||||
"text-nb-gray-100 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
}
|
||||
size={12}
|
||||
className={cn(
|
||||
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
|
||||
iconAlignment === "left" ? "order-first" : "order-last",
|
||||
!alwaysShowIcon && "opacity-0",
|
||||
)}
|
||||
size={11}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const DropdownInfoText = ({ children }: Props) => {
|
||||
export const DropdownInfoText = ({ children, className }: Props) => {
|
||||
return (
|
||||
<div className={"text-center pt-2 mb-6 text-nb-gray-400"}>{children}</div>
|
||||
<div className={cn("text-center pt-2 mb-6 text-nb-gray-400", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,16 +2,51 @@ import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
export const fancyToggleSwitchVariants = cva([], {
|
||||
variants: {
|
||||
variant: {
|
||||
default: ["px-6 py-4 border rounded-md"],
|
||||
blank: null,
|
||||
},
|
||||
state: {
|
||||
true: null,
|
||||
false: null,
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: "default",
|
||||
state: true,
|
||||
className: ["border-nb-gray-800 bg-nb-gray-900/70"],
|
||||
},
|
||||
{
|
||||
variant: "default",
|
||||
state: false,
|
||||
className: [
|
||||
"border-nb-gray-910 bg-nb-gray-900/30 hover:bg-nb-gray-900/40",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export type FancyToggleSwitchVariants = VariantProps<
|
||||
typeof fancyToggleSwitchVariants
|
||||
>;
|
||||
|
||||
interface Props extends FancyToggleSwitchVariants {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
helpText?: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
dataCy?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FancyToggleSwitch({
|
||||
value,
|
||||
onChange,
|
||||
@@ -19,28 +54,49 @@ export default function FancyToggleSwitch({
|
||||
label,
|
||||
children,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
dataCy,
|
||||
className,
|
||||
variant = "default",
|
||||
}: Readonly<Props>) {
|
||||
const handleToggle = () => {
|
||||
if (disabled) return;
|
||||
onChange(!value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleToggle();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onChange(!value);
|
||||
}}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
role={"switch"}
|
||||
aria-checked={value}
|
||||
className={cn(
|
||||
"px-5 py-3.5 border rounded-md cursor-pointer transition-all duration-300 relative z-[1]",
|
||||
value
|
||||
? "border-nb-gray-800 bg-nb-gray-900/70"
|
||||
: "border-nb-gray-800 bg-nb-gray-900/30 hover:bg-nb-gray-900/40",
|
||||
"cursor-pointer transition-all duration-300 relative z-[1]",
|
||||
"inline-block text-left w-full",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
fancyToggleSwitchVariants({ variant, state: value }),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={"flex justify-between gap-10 "}>
|
||||
<div className={"flex justify-between gap-10"}>
|
||||
<div className={"max-w-sm"}>
|
||||
<Label>{label}</Label>
|
||||
<HelpText margin={false}>{helpText}</HelpText>
|
||||
</div>
|
||||
<div className={"mt-2"}>
|
||||
<ToggleSwitch checked={value} onCheckedChange={onChange} />
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<ToggleSwitch
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
dataCy={dataCy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>{children && value ? children : null}</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ type Props = {
|
||||
keepOpen?: boolean;
|
||||
customOpen?: boolean;
|
||||
customOnOpenChange?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
delayDuration?: number;
|
||||
skipDelayDuration?: number;
|
||||
} & TooltipProps;
|
||||
export default function FullTooltip({
|
||||
children,
|
||||
@@ -37,6 +39,8 @@ export default function FullTooltip({
|
||||
keepOpen = false,
|
||||
customOpen,
|
||||
customOnOpenChange,
|
||||
delayDuration = 1,
|
||||
skipDelayDuration = 300,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(!!keepOpen);
|
||||
|
||||
@@ -46,9 +50,13 @@ export default function FullTooltip({
|
||||
};
|
||||
|
||||
return !disabled ? (
|
||||
<TooltipProvider disableHoverableContent={!interactive}>
|
||||
<TooltipProvider
|
||||
disableHoverableContent={!interactive}
|
||||
delayDuration={delayDuration}
|
||||
skipDelayDuration={skipDelayDuration}
|
||||
>
|
||||
<Tooltip
|
||||
delayDuration={1}
|
||||
delayDuration={delayDuration}
|
||||
open={customOpen || open}
|
||||
onOpenChange={customOnOpenChange || handleOpen}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { Radio, RadioItem } from "@components/Radio";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { AccessControlGroupCount } from "@components/ui/AccessControlGroupCount";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import GroupBadgeWithEditPeers from "@components/ui/GroupBadgeWithEditPeers";
|
||||
import ResourceBadge from "@components/ui/ResourceBadge";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
|
||||
import { useSearch } from "@hooks/useSearch";
|
||||
@@ -21,6 +26,7 @@ import {
|
||||
FolderGit2,
|
||||
GlobeIcon,
|
||||
Layers3,
|
||||
Layers3Icon,
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
SearchIcon,
|
||||
@@ -28,11 +34,13 @@ import {
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import type {Group, GroupPeer, GroupResource} from "@/interfaces/Group";
|
||||
import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
|
||||
interface MultiSelectProps {
|
||||
values: Group[];
|
||||
@@ -48,6 +56,11 @@ interface MultiSelectProps {
|
||||
showRoutes?: boolean;
|
||||
disabledGroups?: Group[];
|
||||
dataCy?: string;
|
||||
showResourceCounter?: boolean;
|
||||
showResources?: boolean;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
export function PeerGroupSelector({
|
||||
onChange,
|
||||
@@ -63,12 +76,20 @@ export function PeerGroupSelector({
|
||||
showRoutes = false,
|
||||
disabledGroups,
|
||||
dataCy = "group-selector-dropdown",
|
||||
showResourceCounter = true,
|
||||
showResources = false,
|
||||
resource,
|
||||
onResourceChange,
|
||||
placeholder = "Add or select group(s)...",
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
|
||||
useGroups();
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
const [search, setSearch] = useState("");
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
|
||||
// Update dropdown options when groups change
|
||||
useEffect(() => {
|
||||
@@ -100,25 +121,40 @@ export function PeerGroupSelector({
|
||||
|
||||
// Add group to the groupOptions if it does not exist
|
||||
const selectGroup = (name: string) => {
|
||||
onResourceChange?.(undefined);
|
||||
const group = groups?.find((group) => group.name == name);
|
||||
const option = dropdownOptions.find((option) => option.name == name);
|
||||
const groupPeers: GroupPeer[] | undefined =
|
||||
(group?.peers as GroupPeer[]) || [];
|
||||
const groupResources: GroupResource[] | undefined =
|
||||
(group?.resources as GroupResource[]) || [];
|
||||
(group?.resources as GroupResource[]) || [];
|
||||
|
||||
if (peer) groupPeers?.push({ id: peer?.id as string, name: peer?.name });
|
||||
|
||||
if (!group && !option) {
|
||||
addDropdownOptions([{ name: name, peers: groupPeers, resources: groupResources }]);
|
||||
addDropdownOptions([
|
||||
{ name: name, peers: groupPeers, resources: groupResources },
|
||||
]);
|
||||
}
|
||||
|
||||
if (max == 1 && values.length == 1) {
|
||||
onChange([{ name: name, id: group?.id, peers: groupPeers, resources: groupResources }]);
|
||||
onChange([
|
||||
{
|
||||
name: name,
|
||||
id: group?.id,
|
||||
peers: groupPeers,
|
||||
resources: groupResources,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
onChange((previous) => [
|
||||
...previous,
|
||||
{ name: name, id: group?.id, peers: groupPeers, resources: groupResources },
|
||||
{
|
||||
name: name,
|
||||
id: group?.id,
|
||||
peers: groupPeers,
|
||||
resources: groupResources,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -153,6 +189,8 @@ export function PeerGroupSelector({
|
||||
|
||||
const [slice, setSlice] = useState(10);
|
||||
|
||||
const [tab, setTab] = useState("groups");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
@@ -175,16 +213,41 @@ export function PeerGroupSelector({
|
||||
open,
|
||||
);
|
||||
|
||||
// Reset the search input when switching tabs
|
||||
useEffect(() => {
|
||||
setSearch("");
|
||||
setTimeout(() => {
|
||||
searchRef.current?.focus();
|
||||
}, 0);
|
||||
}, [tab]);
|
||||
|
||||
const searchPlaceholder =
|
||||
tab === "groups"
|
||||
? 'Search groups or add new group by pressing "Enter"...'
|
||||
: "Search resource...";
|
||||
|
||||
const selectResource = (resource?: NetworkResource) => {
|
||||
onResourceChange?.(
|
||||
resource
|
||||
? ({
|
||||
id: resource?.id,
|
||||
type: resource?.type,
|
||||
} as PolicyRuleResource)
|
||||
: undefined,
|
||||
);
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen && search.length > 0) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}, 200);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -204,6 +267,18 @@ export function PeerGroupSelector({
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{resource && showResources && (
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectResource();
|
||||
}}
|
||||
showX={true}
|
||||
/>
|
||||
)}
|
||||
{values.map((group) => {
|
||||
return (
|
||||
<div
|
||||
@@ -247,8 +322,8 @@ export function PeerGroupSelector({
|
||||
);
|
||||
})}
|
||||
|
||||
{values.length == 0 && (
|
||||
<span className={"pl-1"}>Add or select group(s)...</span>
|
||||
{values.length == 0 && !resource && (
|
||||
<span className={"pl-1"}>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -261,7 +336,7 @@ export function PeerGroupSelector({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
@@ -292,9 +367,7 @@ export function PeerGroupSelector({
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={
|
||||
'Search groups or add new group by pressing "Enter"...'
|
||||
}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
@@ -320,100 +393,124 @@ export function PeerGroupSelector({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommandGroup>
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3",
|
||||
sortedDropdownOptions.length == 0 && !search && "py-0",
|
||||
)}
|
||||
>
|
||||
{searchedGroupNotFound && (
|
||||
<CommandItem
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
toggleGroupByName(search);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
|
||||
{showResources && <TabTriggers searchRef={searchRef} />}
|
||||
<TabsContent value={"groups"} className={"p-0 my-0"}>
|
||||
<CommandGroup>
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3",
|
||||
sortedDropdownOptions.length == 0 && !search && "py-0",
|
||||
)}
|
||||
>
|
||||
<Badge variant={"gray-ghost"}>
|
||||
{folderIcon}
|
||||
{search}
|
||||
</Badge>
|
||||
<div className={"text-neutral-500 dark:text-nb-gray-300"}>
|
||||
Add this group by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)}
|
||||
|
||||
{sortedDropdownOptions.slice(0, slice).map((option) => {
|
||||
const isSelected =
|
||||
values.find((group) => group.name == option.name) !=
|
||||
undefined;
|
||||
const peerCount =
|
||||
option.peers?.length ?? option?.peers_count ?? 0;
|
||||
|
||||
const isDisabled = disabledGroups
|
||||
? disabledGroups?.findIndex((g) => g.id === option.id) !==
|
||||
-1
|
||||
: false;
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
This group is already part of the routing peer and can
|
||||
not be used for the access control groups.
|
||||
</div>
|
||||
}
|
||||
disabled={!isDisabled}
|
||||
className={"w-full block"}
|
||||
key={option.name}
|
||||
>
|
||||
{searchedGroupNotFound && (
|
||||
<CommandItem
|
||||
key={option.name}
|
||||
value={option.name + option.id}
|
||||
disabled={isDisabled}
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
if (peer != undefined && option.name == "All") return; // Prevent removing the "All" group
|
||||
if (isDisabled) return;
|
||||
toggleGroupByName(option.name);
|
||||
toggleGroupByName(search);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
className={cn(isDisabled && "opacity-40")}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<GroupBadge group={option} showNewBadge={true} />
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
{option?.id && showRoutes && (
|
||||
<AccessControlGroupCount group_id={option.id} />
|
||||
)}
|
||||
|
||||
<ResourcesCounter group={option} />
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{peerIcon}
|
||||
{peerCount} Peer(s)
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
<Badge variant={"gray-ghost"}>
|
||||
{folderIcon}
|
||||
{search}
|
||||
</Badge>
|
||||
<div
|
||||
className={"text-neutral-500 dark:text-nb-gray-300"}
|
||||
>
|
||||
Add this group by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</FullTooltip>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{sortedDropdownOptions.slice(0, slice).map((option) => {
|
||||
const isSelected =
|
||||
values.find((group) => group.name == option.name) !=
|
||||
undefined;
|
||||
const peerCount =
|
||||
option.peers?.length ?? option?.peers_count ?? 0;
|
||||
|
||||
const isDisabled = disabledGroups
|
||||
? disabledGroups?.findIndex(
|
||||
(g) => g.id === option.id,
|
||||
) !== -1
|
||||
: false;
|
||||
|
||||
if (hideAllGroup && option?.name === "All") return;
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
This group is already part of the routing peer and
|
||||
can not be used for the access control groups.
|
||||
</div>
|
||||
}
|
||||
disabled={!isDisabled}
|
||||
className={"w-full block"}
|
||||
key={option.name}
|
||||
>
|
||||
<CommandItem
|
||||
key={option.name}
|
||||
value={option.name + option.id}
|
||||
disabled={isDisabled}
|
||||
onSelect={() => {
|
||||
if (peer != undefined && option.name == "All")
|
||||
return; // Prevent removing the "All" group
|
||||
if (isDisabled) return;
|
||||
toggleGroupByName(option.name);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
className={cn(isDisabled && "opacity-40")}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<GroupBadge group={option} showNewBadge={true} />
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
{option?.id && showRoutes && (
|
||||
<AccessControlGroupCount group_id={option.id} />
|
||||
)}
|
||||
|
||||
{showResourceCounter && (
|
||||
<ResourcesCounter group={option} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{peerIcon}
|
||||
{peerCount} Peer(s)
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</FullTooltip>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</TabsContent>
|
||||
{showResources && (
|
||||
<TabsContent value={"resources"} className={"p-0 my-0"}>
|
||||
<ResourcesList
|
||||
search={search}
|
||||
resources={resources}
|
||||
isLoading={isLoading}
|
||||
value={resource}
|
||||
onChange={selectResource}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
@@ -421,6 +518,43 @@ export function PeerGroupSelector({
|
||||
);
|
||||
}
|
||||
|
||||
const TabTriggers = ({
|
||||
searchRef,
|
||||
}: {
|
||||
searchRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
}) => {
|
||||
return (
|
||||
<TabsList justify={"start"} className={"px-3"}>
|
||||
<TabsTrigger
|
||||
value={"groups"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<FolderGit2
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Groups
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={"resources"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<Layers3Icon
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Resource
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourcesCounter = ({ group }: { group: Group }) => {
|
||||
return group?.resources_count && group.resources_count > 0 ? (
|
||||
<div
|
||||
@@ -440,11 +574,19 @@ const resourcesSearchPredicate = (item: NetworkResource, query: string) => {
|
||||
return item.address.toLowerCase().includes(lowerCaseQuery);
|
||||
};
|
||||
|
||||
const ResourcesList = ({ search }: { search: string }) => {
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
|
||||
const ResourcesList = ({
|
||||
search,
|
||||
resources,
|
||||
isLoading,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
search: string;
|
||||
resources?: NetworkResource[];
|
||||
isLoading: boolean;
|
||||
value?: PolicyRuleResource;
|
||||
onChange: (resource: NetworkResource) => void;
|
||||
}) => {
|
||||
const [filteredItems, _, setSearch] = useSearch(
|
||||
resources || [],
|
||||
resourcesSearchPredicate,
|
||||
@@ -455,16 +597,43 @@ const ResourcesList = ({ search }: { search: string }) => {
|
||||
setSearch(search);
|
||||
}, [search, setSearch]);
|
||||
|
||||
return isLoading ? (
|
||||
<>Loading...</>
|
||||
) : (
|
||||
filteredItems.length > 0 && (
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}>
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (search != "" && filteredItems.length == 0) {
|
||||
return (
|
||||
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
|
||||
There are no resources matching your search. Please try a different
|
||||
search term.
|
||||
</DropdownInfoText>
|
||||
);
|
||||
}
|
||||
|
||||
if (search == "" && filteredItems.length == 0) {
|
||||
return (
|
||||
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
|
||||
There are no resources available yet. <br />
|
||||
Go to <InlineLink href={"/networks"}>Networks</InlineLink> to add some
|
||||
resources.
|
||||
</DropdownInfoText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Radio defaultValue={value?.id} name={"resource"} value={value?.id}>
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={(option) => null}
|
||||
onSelect={onChange}
|
||||
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
|
||||
renderItem={(res) => {
|
||||
const isSelected = false;
|
||||
|
||||
return (
|
||||
<Fragment key={res.id}>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
@@ -478,22 +647,13 @@ const ResourcesList = ({ search }: { search: string }) => {
|
||||
}}
|
||||
>
|
||||
{res.type === "host" && (
|
||||
<WorkflowIcon
|
||||
size={12}
|
||||
className={"text-yellow-400 shrink-0"}
|
||||
/>
|
||||
<WorkflowIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{res.type === "domain" && (
|
||||
<GlobeIcon
|
||||
size={12}
|
||||
className={"text-yellow-400 shrink-0"}
|
||||
/>
|
||||
<GlobeIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{res.type === "subnet" && (
|
||||
<NetworkIcon
|
||||
size={12}
|
||||
className={"text-yellow-400 shrink-0"}
|
||||
/>
|
||||
<NetworkIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
|
||||
<TextWithTooltip text={res?.name || ""} maxChars={20} />
|
||||
@@ -506,13 +666,14 @@ const ResourcesList = ({ search }: { search: string }) => {
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
{res.address}
|
||||
<RadioItem value={res.id} />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
</Radio>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { DropdownInput } from "@components/DropdownInput";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { useSearch } from "@hooks/useSearch";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isRoutingPeerSupported } from "@utils/version";
|
||||
import { sortBy, unionBy } from "lodash";
|
||||
import { ChevronsUpDown, MapPin } from "lucide-react";
|
||||
import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { FcLinux } from "react-icons/fc";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
const MapPinIcon = memo(() => <MapPin size={12} />);
|
||||
MapPinIcon.displayName = "MapPinIcon";
|
||||
|
||||
const LinuxIcon = memo(() => (
|
||||
<span className={"grayscale brightness-[100%] contrast-[40%]"}>
|
||||
<FcLinux className={"text-white text-lg min-w-[20px] brightness-150"} />
|
||||
</span>
|
||||
));
|
||||
LinuxIcon.displayName = "LinuxIcon";
|
||||
|
||||
interface MultiSelectProps {
|
||||
value?: Peer;
|
||||
onChange: React.Dispatch<React.SetStateAction<Peer | undefined>>;
|
||||
@@ -63,11 +58,6 @@ export function PeerSelector({
|
||||
// Sort
|
||||
let options = sortBy([...peers], "name") as Peer[];
|
||||
|
||||
// Filter out peers that are not linux
|
||||
options = options.filter((peer) => {
|
||||
return getOperatingSystem(peer.os) === OperatingSystem.LINUX;
|
||||
});
|
||||
|
||||
// Filter out excluded peers
|
||||
if (excludedPeers) {
|
||||
options = options.filter((peer) => {
|
||||
@@ -128,8 +118,7 @@ export function PeerSelector({
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center gap-2.5 text-sm"}>
|
||||
<LinuxIcon />
|
||||
<TextWithTooltip text={value.name} maxChars={20} />
|
||||
<TextWithTooltip text={value.name} maxChars={22} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -151,7 +140,7 @@ export function PeerSelector({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
hideWhenDetached={false}
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: width,
|
||||
}}
|
||||
@@ -166,15 +155,15 @@ export function PeerSelector({
|
||||
placeholder={"Search for peers by name or ip..."}
|
||||
/>
|
||||
|
||||
{unfilteredItems.length == 0 && (
|
||||
<DropdownInfoText>
|
||||
{
|
||||
"Seems like you don't have any linux peers to assign as a routing peer."
|
||||
}
|
||||
</DropdownInfoText>
|
||||
{unfilteredItems.length == 0 && !search && (
|
||||
<div className={"max-w-xs mx-auto"}>
|
||||
<DropdownInfoText>
|
||||
{"No peers available to select."}
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredItems.length == 0 && (
|
||||
{filteredItems.length == 0 && search != "" && (
|
||||
<DropdownInfoText>
|
||||
There are no peers matching your search.
|
||||
</DropdownInfoText>
|
||||
@@ -183,10 +172,35 @@ export function PeerSelector({
|
||||
{filteredItems.length > 0 && (
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={togglePeer}
|
||||
onSelect={(item) => {
|
||||
const isSupported = isRoutingPeerSupported(
|
||||
item.version,
|
||||
item.os,
|
||||
);
|
||||
if (!isSupported) return;
|
||||
togglePeer(item);
|
||||
}}
|
||||
renderItem={(option) => {
|
||||
const os = getOperatingSystem(option.os);
|
||||
const isSupported = isRoutingPeerSupported(
|
||||
option.version,
|
||||
option.os,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FullTooltip
|
||||
disabled={isSupported}
|
||||
interactive={false}
|
||||
delayDuration={200}
|
||||
skipDelayDuration={350}
|
||||
className={"w-full flex items-center justify-between"}
|
||||
content={
|
||||
<div className={"max-w-[240px] text-xs"}>
|
||||
Please update NetBird to at least{" "}
|
||||
<span className={"text-netbird"}>v0.36.6</span> or later
|
||||
to use this peer as a routing peer.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 text-sm",
|
||||
@@ -195,8 +209,35 @@ export function PeerSelector({
|
||||
: "text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<LinuxIcon />
|
||||
<TextWithTooltip text={option.name} maxChars={20} />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
|
||||
"w-4 h-4 shrink-0",
|
||||
os === OperatingSystem.WINDOWS && "p-[2.5px]",
|
||||
os === OperatingSystem.APPLE && "p-[2.7px]",
|
||||
os === OperatingSystem.FREEBSD && "p-[1.5px]",
|
||||
!isSupported && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<OSLogo os={option.os} />
|
||||
</div>
|
||||
|
||||
<div className={cn(!isSupported && "opacity-50")}>
|
||||
<TextWithTooltip
|
||||
text={option.name}
|
||||
maxChars={22}
|
||||
hideTooltip={!isSupported}
|
||||
/>
|
||||
</div>
|
||||
{!isSupported && (
|
||||
<div className={"relative"}>
|
||||
<span className="animate-ping absolute left-0 inline-flex h-[14px] w-[14px] rounded-full bg-netbird opacity-20"></span>
|
||||
<ArrowUpCircleIcon
|
||||
size={14}
|
||||
className={"text-netbird"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -205,12 +246,13 @@ export function PeerSelector({
|
||||
value && value.id == option.id
|
||||
? "text-white"
|
||||
: "text-nb-gray-300",
|
||||
!isSupported && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<MapPinIcon />
|
||||
{option.ip}
|
||||
</div>
|
||||
</>
|
||||
</FullTooltip>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
71
src/components/Radio.tsx
Normal file
71
src/components/Radio.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as RadioPrimitive from "@radix-ui/react-radio-group";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
type RadioVariants = VariantProps<typeof variants>;
|
||||
|
||||
const variants = cva([], {
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"dark:data-[state=unchecked]:bg-nb-gray-950 dark:border-nb-gray-900 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300",
|
||||
"dark:data-[state=checked]:bg-netbird",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const Radio = forwardRef<
|
||||
React.ElementRef<typeof RadioPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioPrimitive.Root> & RadioVariants
|
||||
>(
|
||||
(
|
||||
{ className, children, variant = "default", defaultValue, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<RadioPrimitive.Root
|
||||
ref={ref}
|
||||
defaultValue={defaultValue}
|
||||
name={props.name}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RadioPrimitive.Root>
|
||||
),
|
||||
);
|
||||
Radio.displayName = RadioPrimitive.Root.displayName;
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
className?: string;
|
||||
} & RadioVariants;
|
||||
|
||||
const RadioItem = ({ value, className, variant = "default" }: Props) => {
|
||||
return (
|
||||
<RadioPrimitive.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
variants({ variant }),
|
||||
"border-neutral-900",
|
||||
"peer h-5 w-5 shrink-0 rounded-full border",
|
||||
"ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 relative",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RadioPrimitive.Indicator asChild={true}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 bg-netbird absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center justify-center rounded-full",
|
||||
"data-[state=checked]:bg-white data-[state=checked]:text-neutral-50 ",
|
||||
)}
|
||||
></div>
|
||||
</RadioPrimitive.Indicator>
|
||||
</RadioPrimitive.Item>
|
||||
);
|
||||
};
|
||||
RadioItem.displayName = RadioPrimitive.Item.displayName;
|
||||
|
||||
export { Radio, RadioItem };
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
@@ -15,46 +13,31 @@ const ScrollArea = React.forwardRef<
|
||||
>(({ className, children, withoutViewport = false, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative will-change-scroll webkit-scroll",
|
||||
className,
|
||||
"overflow-hidden",
|
||||
)}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
{withoutViewport ? (
|
||||
children
|
||||
) : (
|
||||
<ScrollAreaViewport disableOverflowY={false}>
|
||||
{children}
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaViewport>{children}</ScrollAreaViewport>
|
||||
)}
|
||||
<ScrollBar />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
type AdditionalScrollAreaViewportProps = {
|
||||
disableOverflowY?: boolean;
|
||||
};
|
||||
|
||||
const ScrollAreaViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport> &
|
||||
AdditionalScrollAreaViewportProps
|
||||
>(({ disableOverflowY = true, ...props }, ref) => {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={ref}
|
||||
className="h-full w-full rounded-[inherit] will-change-scroll webkit-scroll"
|
||||
{...props}
|
||||
style={
|
||||
disableOverflowY ? { overflowY: undefined, ...props.style } : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={ref}
|
||||
className={cn("h-full w-full rounded-[inherit]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ScrollAreaViewport.displayName = ScrollAreaPrimitive.Viewport.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
@@ -63,14 +46,11 @@ const ScrollBar = React.forwardRef<
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
style={{ boxSizing: "unset", overflow: undefined }}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 border-t border-t-transparent p-[1px]",
|
||||
"flex select-none touch-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 p-[1px]",
|
||||
orientation === "horizontal" && "w-full h-2.5 p-[1px] bottom-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -79,6 +59,7 @@ const ScrollBar = React.forwardRef<
|
||||
className={cn(
|
||||
"relative rounded-full bg-neutral-200 dark:bg-nb-gray-800",
|
||||
orientation === "vertical" && "flex-1",
|
||||
orientation === "horizontal" && "h-full",
|
||||
)}
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export default function Separator() {
|
||||
return (
|
||||
<span
|
||||
className={"h-[1px] w-full dark:bg-nb-gray-900 bg-nb-gray-100 block"}
|
||||
></span>
|
||||
);
|
||||
return <span className={"h-[1px] w-full bg-zinc-700/40 block"}></span>;
|
||||
}
|
||||
|
||||
@@ -60,10 +60,12 @@ export default function SidebarItem({
|
||||
<li className={"px-4 cursor-pointer"}>
|
||||
<button
|
||||
className={classNames(
|
||||
"rounded-lg text-[.95rem] w-full ",
|
||||
"rounded-lg text-[.87rem] w-full ",
|
||||
"font-normal ",
|
||||
className,
|
||||
isChild ? "pl-7 pr-2 py-2 mt-1 mb-0.5" : "py-2 px-3",
|
||||
isChild
|
||||
? "pl-7 pr-2 py-[.45rem] mt-1 mb-0.5"
|
||||
: "py-[.45rem] px-3",
|
||||
isActive
|
||||
? "text-gray-900 bg-gray-200 dark:text-white dark:bg-nb-gray-900"
|
||||
: "text-gray-600 hover:bg-gray-200 dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/50",
|
||||
|
||||
@@ -4,9 +4,18 @@ import React from "react";
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
horizontal?: boolean;
|
||||
};
|
||||
export default function Steps({ children, className }: Props) {
|
||||
return <div className={cn("pt-4", className)}>{children}</div>;
|
||||
export default function Steps({
|
||||
children,
|
||||
className,
|
||||
horizontal = false,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div className={cn("pt-4", horizontal && "flex", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StepProps = {
|
||||
@@ -14,21 +23,32 @@ type StepProps = {
|
||||
step: number;
|
||||
line?: boolean;
|
||||
center?: boolean;
|
||||
horizontal?: boolean;
|
||||
};
|
||||
|
||||
const Step = ({ children, step, line = true, center = false }: StepProps) => {
|
||||
const Step = ({
|
||||
children,
|
||||
step,
|
||||
line = true,
|
||||
center = false,
|
||||
horizontal,
|
||||
}: StepProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-4 items-start min-w-full justify-start relative pb-6 -mx-1.5 group px-[2px]",
|
||||
"flex gap-4 items-start justify-start relative pb-6 -mx-1.5 group px-[2px]",
|
||||
center && "items-center",
|
||||
horizontal ? "flex-col items-center" : "min-w-full",
|
||||
)}
|
||||
>
|
||||
{line && (
|
||||
<span
|
||||
className={
|
||||
"h-full w-[2px] bg-nb-gray-100 dark:bg-nb-gray-800 absolute left-0 ml-[18px] z-0 transition-all"
|
||||
}
|
||||
className={cn(
|
||||
"bg-nb-gray-100 dark:bg-nb-gray-800 z-0 transition-all",
|
||||
horizontal
|
||||
? "w-full h-[2px] absolute mt-[16px] transform translate-x-1/2"
|
||||
: "h-full w-[2px] absolute left-0 ml-[18px]",
|
||||
)}
|
||||
></span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -36,29 +36,36 @@ const switchVariants = cva("", {
|
||||
|
||||
const ToggleSwitch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> & SwitchVariants
|
||||
>(({ className, size = "default", variant = "default", ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 ",
|
||||
className,
|
||||
switchVariants({ size, variant }),
|
||||
)}
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> &
|
||||
SwitchVariants & { dataCy?: string }
|
||||
>(
|
||||
(
|
||||
{ className, size = "default", variant = "default", dataCy, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
switchVariants({ "thumb-size": size }),
|
||||
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 dark:bg-white",
|
||||
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 ",
|
||||
className,
|
||||
switchVariants({ size, variant }),
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
{...props}
|
||||
data-cy={dataCy}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
switchVariants({ "thumb-size": size }),
|
||||
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 dark:bg-white",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
),
|
||||
);
|
||||
ToggleSwitch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { ToggleSwitch };
|
||||
|
||||
@@ -10,6 +10,9 @@ const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export const tooltipClasses =
|
||||
"z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50";
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
@@ -19,10 +22,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
className={cn(tooltipClasses, className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
|
||||
@@ -51,7 +51,7 @@ function List({ children }: { children: React.ReactNode }) {
|
||||
<Tabs.List
|
||||
className={cn(
|
||||
"px-4 py-4 whitespace-nowrap overflow-y-hidden shrink-0 no-scrollbar",
|
||||
"lg:h-full items-start bg-nb-gray border-b border-nb-gray-930",
|
||||
"lg:h-full items-start bg-nb-gray border-b-0 border-nb-gray-930",
|
||||
"flex lg:flex-col lg:gap-1",
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -11,13 +11,15 @@ type Props<T extends { id?: string }> = {
|
||||
items: T[];
|
||||
onSelect: (item: T) => void;
|
||||
renderItem?: (item: T) => React.ReactNode;
|
||||
itemClassName?: string;
|
||||
};
|
||||
|
||||
export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
items,
|
||||
onSelect,
|
||||
renderItem,
|
||||
}: Props<T>) {
|
||||
itemClassName,
|
||||
}: Readonly<Props<T>>) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
@@ -81,8 +83,9 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
<VirtualScrollListItemWrapper
|
||||
onMouseEnter={() => setSelected(index)}
|
||||
id={option.id}
|
||||
onClick={() => onClick(option as T)}
|
||||
onClick={() => onClick(option)}
|
||||
ariaSelected={selected === index}
|
||||
className={itemClassName}
|
||||
>
|
||||
{renderMemoizedItem ? renderMemoizedItem(option) : option.id}
|
||||
</VirtualScrollListItemWrapper>
|
||||
@@ -103,10 +106,18 @@ type ItemWrapperProps = {
|
||||
onMouseEnter?: () => void;
|
||||
onClick?: () => void;
|
||||
ariaSelected?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const VirtualScrollListItemWrapper = memo(
|
||||
({ id, children, onClick, onMouseEnter, ariaSelected }: ItemWrapperProps) => {
|
||||
({
|
||||
id,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
ariaSelected,
|
||||
className,
|
||||
}: ItemWrapperProps) => {
|
||||
return (
|
||||
<div
|
||||
key={id ?? undefined}
|
||||
@@ -118,6 +129,7 @@ export const VirtualScrollListItemWrapper = memo(
|
||||
className={cn(
|
||||
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md",
|
||||
"bg-transparent dark:aria-selected:bg-nb-gray-800/50",
|
||||
className,
|
||||
)}
|
||||
aria-selected={ariaSelected}
|
||||
role={"listitem"}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Badge from "@components/Badge";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FolderGit2, XIcon } from "lucide-react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
|
||||
@@ -13,6 +15,7 @@ type Props = {
|
||||
className?: string;
|
||||
showNewBadge?: boolean;
|
||||
};
|
||||
|
||||
export default function GroupBadge({
|
||||
onClick,
|
||||
group,
|
||||
@@ -20,7 +23,7 @@ export default function GroupBadge({
|
||||
children,
|
||||
className,
|
||||
showNewBadge = false,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
const isNew = !group?.id;
|
||||
|
||||
return (
|
||||
@@ -35,20 +38,10 @@ export default function GroupBadge({
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
<FolderGit2 size={12} className={"shrink-0"} />
|
||||
|
||||
<TextWithTooltip text={group?.name || ""} maxChars={20} />
|
||||
<GroupBadgeIcon id={group?.id} issued={group?.issued} />
|
||||
<TruncatedText text={group?.name || ""} maxChars={20} />
|
||||
{children}
|
||||
{isNew && showNewBadge && (
|
||||
<span
|
||||
className={
|
||||
"text-[7px] relative top-[.25px] leading-[0] bg-green-900 border border-green-500/20 py-1.5 px-1 rounded-[3px] text-green-400"
|
||||
}
|
||||
>
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isNew && showNewBadge && <SmallBadge />}
|
||||
{showX && (
|
||||
<XIcon
|
||||
size={12}
|
||||
|
||||
32
src/components/ui/GroupBadgeIcon.tsx
Normal file
32
src/components/ui/GroupBadgeIcon.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import EntraIcon from "@/assets/icons/EntraIcon";
|
||||
import GoogleIcon from "@/assets/icons/GoogleIcon";
|
||||
import JWTIcon from "@/assets/icons/JWTIcon";
|
||||
import OktaIcon from "@/assets/icons/OktaIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { GroupIssued } from "@/interfaces/Group";
|
||||
import { useGroupIdentification } from "@/modules/groups/useGroupIdentification";
|
||||
|
||||
export const GroupBadgeIcon = ({
|
||||
id,
|
||||
issued,
|
||||
}: {
|
||||
id?: string;
|
||||
issued?: GroupIssued;
|
||||
}) => {
|
||||
const { groups } = useGroups();
|
||||
const group = groups?.find((g) => g.id === id);
|
||||
|
||||
const { isAzureGroup, isGoogleGroup, isOktaGroup, isJWTGroup } =
|
||||
useGroupIdentification({ id, issued: issued ?? group?.issued });
|
||||
|
||||
if (isGoogleGroup)
|
||||
return <GoogleIcon size={11} className={"shrink-0 mr-0.5"} />;
|
||||
if (isAzureGroup)
|
||||
return <EntraIcon size={13} className={"shrink-0 mr-0.5"} />;
|
||||
if (isOktaGroup) return <OktaIcon size={11} className={"shrink-0 mr-0.5"} />;
|
||||
if (isJWTGroup) return <JWTIcon size={12} className={"shrink-0"} />;
|
||||
|
||||
return <FolderGit2 size={12} className={"shrink-0"} />;
|
||||
};
|
||||
@@ -9,8 +9,8 @@ export default function LoginExpiredBadge({ loginExpired }: Props) {
|
||||
return loginExpired ? (
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipTrigger>
|
||||
<Badge variant={"red"} className={"px-3"}>
|
||||
<AlertTriangle size={13} className={"mr-1"} />
|
||||
<Badge variant={"red"} className={"px-2"}>
|
||||
<AlertTriangle size={12} />
|
||||
Login required
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
|
||||
16
src/components/ui/NewBadge.tsx
Normal file
16
src/components/ui/NewBadge.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
text?: string;
|
||||
};
|
||||
export const NewBadge = ({ text = "NEW" }: Props) => {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
"text-[7px] relative top-[.25px] leading-[0] bg-green-900 border border-green-500/20 py-1.5 px-1 rounded-[3px] text-green-400"
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
58
src/components/ui/ResourceBadge.tsx
Normal file
58
src/components/ui/ResourceBadge.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Badge from "@components/Badge";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
|
||||
type Props = {
|
||||
resource?: NetworkResource;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
showX?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
export default function ResourceBadge({
|
||||
onClick,
|
||||
resource,
|
||||
showX = false,
|
||||
children,
|
||||
className,
|
||||
}: Readonly<Props>) {
|
||||
if (!resource) return;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={resource.id || resource?.name}
|
||||
useHover={true}
|
||||
data-cy={"resource-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn("transition-all group whitespace-nowrap", className)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{resource.type === "host" && (
|
||||
<WorkflowIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{resource.type === "domain" && (
|
||||
<GlobeIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{resource.type === "subnet" && (
|
||||
<NetworkIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
|
||||
<TruncatedText text={resource?.name || ""} maxChars={20} />
|
||||
{children}
|
||||
{showX && (
|
||||
<XIcon
|
||||
size={12}
|
||||
className={
|
||||
"cursor-pointer group-hover:text-nb-gray-100 transition-all shrink-0"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
39
src/components/ui/SmallBadge.tsx
Normal file
39
src/components/ui/SmallBadge.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const smallBadgeVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
green: "bg-green-900 border border-green-500/20 text-green-400",
|
||||
white: "bg-white/20 border border-white/10 text-white",
|
||||
sky: "bg-sky-900 border border-sky-500/20 text-white",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
text?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
} & VariantProps<typeof smallBadgeVariants>;
|
||||
|
||||
export const SmallBadge = ({
|
||||
text = "NEW",
|
||||
className,
|
||||
variant = "green",
|
||||
children,
|
||||
}: Props) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
smallBadgeVariants({ variant }),
|
||||
"text-[7px] relative -top-[.25px] leading-[0] py-[0.39rem] px-1 rounded-[3px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span className={"relative top-[0.4px]"}>{text}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,8 @@ export default function TextWithTooltip({
|
||||
disabled={charCount <= maxChars || hideTooltip}
|
||||
interactive={false}
|
||||
className={"truncate w-full min-w-0"}
|
||||
skipDelayDuration={350}
|
||||
delayDuration={200}
|
||||
content={
|
||||
<div className={"max-w-xs break-all whitespace-normal text-xs"}>
|
||||
{text}
|
||||
|
||||
78
src/components/ui/TruncatedText.tsx
Normal file
78
src/components/ui/TruncatedText.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
text?: string;
|
||||
className?: string;
|
||||
maxChars?: number;
|
||||
hideTooltip?: boolean;
|
||||
};
|
||||
|
||||
export default function TruncatedText({
|
||||
text,
|
||||
className,
|
||||
maxChars = 40,
|
||||
hideTooltip = false,
|
||||
}: Props) {
|
||||
const charCount = useMemo(() => {
|
||||
if (!text) return 0;
|
||||
return text.length;
|
||||
}, [text]);
|
||||
|
||||
const isDisabled = charCount <= maxChars || hideTooltip;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<div
|
||||
className={"w-full min-w-0 inline-block"}
|
||||
style={{
|
||||
maxWidth: `${maxChars - 2}ch`,
|
||||
}}
|
||||
>
|
||||
<div className={cn(className, "truncate")}>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard.Root
|
||||
openDelay={650}
|
||||
closeDelay={100}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<HoverCard.Trigger asChild={true}>
|
||||
<div
|
||||
className={"w-full min-w-0 inline-block"}
|
||||
style={{
|
||||
maxWidth: `${maxChars - 2}ch`,
|
||||
}}
|
||||
>
|
||||
<div className={cn(className, "truncate")}>{text}</div>
|
||||
</div>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onMouseEnter={() => setOpen(false)}
|
||||
alignOffset={20}
|
||||
sideOffset={4}
|
||||
className={cn(
|
||||
"z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50",
|
||||
className,
|
||||
"px-3 py-1.5",
|
||||
)}
|
||||
>
|
||||
<div className={"text-neutral-300 flex flex-col gap-1"}>
|
||||
<div className={"max-w-xs break-all whitespace-normal text-xs"}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
);
|
||||
}
|
||||
@@ -20,12 +20,13 @@ const PeerContext = React.createContext(
|
||||
peer: Peer;
|
||||
user?: User;
|
||||
peerGroups: Group[];
|
||||
update: (
|
||||
name: string,
|
||||
ssh: boolean,
|
||||
loginExpiration: boolean,
|
||||
approval_required?: boolean,
|
||||
) => Promise<Peer>;
|
||||
update: (props: {
|
||||
name?: string;
|
||||
ssh?: boolean;
|
||||
loginExpiration?: boolean;
|
||||
inactivityExpiration?: boolean;
|
||||
approval_required?: boolean;
|
||||
}) => Promise<Peer>;
|
||||
openSSHDialog: () => Promise<boolean>;
|
||||
deletePeer: () => void;
|
||||
isLoading: boolean;
|
||||
@@ -61,23 +62,30 @@ export default function PeerProvider({ children, peer }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const update = async (
|
||||
name: string,
|
||||
ssh: boolean,
|
||||
loginExpiration: boolean,
|
||||
approval_required?: boolean,
|
||||
) => {
|
||||
const update = async (props: {
|
||||
name?: string;
|
||||
ssh?: boolean;
|
||||
loginExpiration?: boolean;
|
||||
inactivityExpiration?: boolean;
|
||||
approval_required?: boolean;
|
||||
}) => {
|
||||
return peerRequest.put(
|
||||
{
|
||||
peerId: peer?.id,
|
||||
name: name != undefined ? name : peer.name,
|
||||
ssh_enabled: ssh != undefined ? ssh : peer.ssh_enabled,
|
||||
name: props.name != undefined ? props.name : peer.name,
|
||||
ssh_enabled: props.ssh != undefined ? props.ssh : peer.ssh_enabled,
|
||||
login_expiration_enabled:
|
||||
loginExpiration != undefined
|
||||
? loginExpiration
|
||||
props.loginExpiration != undefined
|
||||
? props.loginExpiration
|
||||
: peer.login_expiration_enabled,
|
||||
inactivity_expiration_enabled:
|
||||
props?.inactivityExpiration == undefined
|
||||
? undefined
|
||||
: props.inactivityExpiration,
|
||||
approval_required:
|
||||
approval_required == undefined ? undefined : approval_required,
|
||||
props?.approval_required == undefined
|
||||
? undefined
|
||||
: props.approval_required,
|
||||
},
|
||||
`/${peer.id}`,
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import useFetchApi from "@utils/api";
|
||||
import React, { useMemo } from "react";
|
||||
import { Permission } from "@/interfaces/Permission";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { Role, User } from "@/interfaces/User";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -40,8 +40,8 @@ export const useUsers = () => React.useContext(UsersContext);
|
||||
|
||||
export const useLoggedInUser = () => {
|
||||
const { loggedInUser } = useUsers();
|
||||
const isOwner = loggedInUser ? loggedInUser?.role === "owner" : false;
|
||||
const isAdmin = loggedInUser ? loggedInUser?.role === "admin" : false;
|
||||
const isOwner = loggedInUser ? loggedInUser?.role === Role.Owner : false;
|
||||
const isAdmin = loggedInUser ? loggedInUser?.role === Role.Admin : false;
|
||||
const isUser = !isOwner && !isAdmin;
|
||||
const isOwnerOrAdmin = isOwner || isAdmin;
|
||||
|
||||
|
||||
33
src/hooks/useExpirationState.tsx
Normal file
33
src/hooks/useExpirationState.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { TimeRange, useTimeFormatter } from "@hooks/useTimeFormatter";
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
enabled: boolean;
|
||||
expirationInSeconds: number;
|
||||
timeRange?: TimeRange;
|
||||
};
|
||||
export const useExpirationState = ({
|
||||
enabled,
|
||||
expirationInSeconds,
|
||||
timeRange = ["hours", "days"],
|
||||
}: Props) => {
|
||||
const [isEnabled, setIsEnabled] = useState(enabled);
|
||||
const [expiresInSeconds] = useState(expirationInSeconds || 86400);
|
||||
|
||||
const { value: seconds, time: unit } = useTimeFormatter(
|
||||
expiresInSeconds,
|
||||
timeRange,
|
||||
);
|
||||
|
||||
const [expiresIn, setExpiresIn] = useState(seconds);
|
||||
const [expireInterval, setExpireInterval] = useState<string>(unit);
|
||||
|
||||
return [
|
||||
isEnabled,
|
||||
setIsEnabled,
|
||||
expiresIn,
|
||||
setExpiresIn,
|
||||
expireInterval,
|
||||
setExpireInterval,
|
||||
] as const;
|
||||
};
|
||||
@@ -25,6 +25,7 @@ export function useSetupKeyPlaceholders() {
|
||||
expires_in: 0,
|
||||
usage_limit: null,
|
||||
ephemeral: randomBoolean(),
|
||||
allow_extra_dns_labels: randomBoolean(),
|
||||
} as SetupKey);
|
||||
}
|
||||
|
||||
|
||||
63
src/hooks/useTimeFormatter.tsx
Normal file
63
src/hooks/useTimeFormatter.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type TimeUnit = "seconds" | "minutes" | "hours" | "days";
|
||||
export type TimeRange = TimeUnit[];
|
||||
|
||||
const TIME_CONVERSIONS: Record<string, number> = {
|
||||
seconds: 1,
|
||||
minutes: 60,
|
||||
hours: 3600,
|
||||
days: 86400,
|
||||
};
|
||||
|
||||
interface FormattedTime {
|
||||
value: string;
|
||||
time: TimeUnit | string;
|
||||
}
|
||||
|
||||
export const isValidTimeUnit = (unit: string): unit is TimeUnit => {
|
||||
return unit in TIME_CONVERSIONS;
|
||||
};
|
||||
|
||||
export const convertToSeconds = (
|
||||
value: string,
|
||||
unit: TimeUnit | string,
|
||||
): number => {
|
||||
if (!isValidTimeUnit(unit)) {
|
||||
console.warn(`Invalid time unit: ${unit}`);
|
||||
}
|
||||
return Math.round(parseFloat(value) * TIME_CONVERSIONS[unit]);
|
||||
};
|
||||
|
||||
export const useTimeFormatter = (
|
||||
seconds: number,
|
||||
range: TimeRange,
|
||||
): FormattedTime => {
|
||||
return useMemo(() => {
|
||||
const smallerUnit = range[0];
|
||||
const largestUnit = range[range.length - 1];
|
||||
const largestIndex = range.indexOf(largestUnit);
|
||||
|
||||
if (TIME_CONVERSIONS[smallerUnit] >= TIME_CONVERSIONS[largestUnit]) {
|
||||
console.warn("First unit must be smaller than second unit");
|
||||
}
|
||||
|
||||
if (seconds === TIME_CONVERSIONS.days && largestUnit === "days") {
|
||||
return { value: "24", time: "hours" };
|
||||
}
|
||||
|
||||
// Convert seconds to all units in range
|
||||
const converted = range.map((unit) => {
|
||||
const value = seconds / TIME_CONVERSIONS[unit];
|
||||
return {
|
||||
value: Number.isInteger(value) ? value.toString() : value.toFixed(2),
|
||||
time: unit,
|
||||
};
|
||||
});
|
||||
|
||||
const { value, time } =
|
||||
converted.reverse().find(({ value }) => parseFloat(value) >= 1) ||
|
||||
converted[largestIndex];
|
||||
return { value, time };
|
||||
}, [seconds, range]);
|
||||
};
|
||||
@@ -6,6 +6,8 @@ export interface Account {
|
||||
};
|
||||
peer_login_expiration_enabled: boolean;
|
||||
peer_login_expiration: number;
|
||||
peer_inactivity_expiration_enabled: boolean;
|
||||
peer_inactivity_expiration: number;
|
||||
groups_propagation_enabled: boolean;
|
||||
jwt_groups_enabled: boolean;
|
||||
jwt_groups_claim_name: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Group {
|
||||
peers_count?: number;
|
||||
resources?: GroupResource[] | string[];
|
||||
resources_count?: number;
|
||||
issued?: GroupIssued;
|
||||
|
||||
// Frontend only
|
||||
keepClientState?: boolean;
|
||||
@@ -18,4 +19,10 @@ export interface GroupPeer {
|
||||
export interface GroupResource {
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
}
|
||||
|
||||
export enum GroupIssued {
|
||||
API = "api",
|
||||
INTEGRATION = "integration",
|
||||
JWT = "jwt",
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface NetworkRouter {
|
||||
peer_groups?: string[];
|
||||
metric: number;
|
||||
masquerade: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkResource {
|
||||
@@ -25,4 +26,5 @@ export interface NetworkResource {
|
||||
address: string;
|
||||
groups?: string[] | Group[];
|
||||
type?: "domain" | "host" | "subnet";
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -16,11 +16,14 @@ export interface Peer {
|
||||
user?: User;
|
||||
ui_version?: string;
|
||||
dns_label: string;
|
||||
extra_dns_labels?: string[];
|
||||
last_login: Date;
|
||||
login_expired: boolean;
|
||||
login_expiration_enabled: boolean;
|
||||
inactivity_expiration_enabled: boolean;
|
||||
approval_required: boolean;
|
||||
city_name: string;
|
||||
country_code: string;
|
||||
connection_ip: string;
|
||||
serial_number: string;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ export interface PolicyRule {
|
||||
action: string;
|
||||
protocol: Protocol;
|
||||
ports: string[];
|
||||
sourceResource?: PolicyRuleResource;
|
||||
destinationResource?: PolicyRuleResource;
|
||||
}
|
||||
|
||||
export interface PolicyRuleResource {
|
||||
id: string;
|
||||
type: "domain" | "host" | "subnet" | undefined;
|
||||
}
|
||||
|
||||
export type Protocol = "all" | "tcp" | "udp" | "icmp";
|
||||
|
||||
@@ -66,47 +66,19 @@ export interface Process {
|
||||
}
|
||||
|
||||
export const windowsKernelVersions: SelectOption[] = [
|
||||
{ value: "5.0", label: "Windows 2000" },
|
||||
{ value: "5.1", label: "Windows XP" },
|
||||
{ value: "6.0", label: "Windows Vista" },
|
||||
{ value: "6.1", label: "Windows 7" },
|
||||
{ value: "6.2", label: "Windows 8" },
|
||||
{ value: "6.3", label: "Windows 8.1" },
|
||||
{ value: "10.0", label: "Windows 10" },
|
||||
{ value: "10.0.2", label: "Windows 11" },
|
||||
];
|
||||
|
||||
export const iOSVersions: SelectOption[] = [
|
||||
{ value: "1.0", label: "iPhone OS 1.x" },
|
||||
{ value: "2.0", label: "iPhone OS 2.x" },
|
||||
{ value: "3.0", label: "iPhone OS 3.x" },
|
||||
{ value: "4.0", label: "iOS 4.x" },
|
||||
{ value: "5.0", label: "iOS 5.x" },
|
||||
{ value: "6.0", label: "iOS 6.x" },
|
||||
{ value: "7.0", label: "iOS 7.x" },
|
||||
{ value: "8.0", label: "iOS 8.x" },
|
||||
{ value: "9.0", label: "iOS 9.x" },
|
||||
{ value: "10.0", label: "iOS 10.x" },
|
||||
{ value: "11.0", label: "iOS 11.x" },
|
||||
{ value: "12.0", label: "iOS 12.x" },
|
||||
{ value: "13.0", label: "iOS 13.x" },
|
||||
{ value: "14.0", label: "iOS 14.x" },
|
||||
{ value: "15.0", label: "iOS 15.x" },
|
||||
{ value: "16.0", label: "iOS 16.x" },
|
||||
{ value: "17.0", label: "iOS 17.x" },
|
||||
{ value: "18.0", label: "iOS 18.x" },
|
||||
];
|
||||
|
||||
export const macOSVersions: SelectOption[] = [
|
||||
{ value: "10.0", label: "Mac OS X Cheetah" },
|
||||
{ value: "10.1", label: "Mac OS X Puma" },
|
||||
{ value: "10.2", label: "Mac OS X Jaguar" },
|
||||
{ value: "10.3", label: "Mac OS X Panther" },
|
||||
{ value: "10.4", label: "Mac OS X Tiger" },
|
||||
{ value: "10.5", label: "Mac OS X Leopard" },
|
||||
{ value: "10.6", label: "Mac OS X Snow Leopard" },
|
||||
{ value: "10.7", label: "Mac OS X Lion" },
|
||||
{ value: "10.8", label: "OS X Mountain Lion" },
|
||||
{ value: "10.9", label: "OS X Mavericks" },
|
||||
{ value: "10.10", label: "OS X Yosemite" },
|
||||
{ value: "10.11", label: "OS X El Capitan" },
|
||||
{ value: "10.12", label: "macOS Sierra" },
|
||||
@@ -117,21 +89,10 @@ export const macOSVersions: SelectOption[] = [
|
||||
{ value: "12.0", label: "macOS Monterey" },
|
||||
{ value: "13.0", label: "macOS Ventura" },
|
||||
{ value: "14.0", label: "macOS Sonoma" },
|
||||
{ value: "15.0", label: "macOS Sequoia" },
|
||||
];
|
||||
|
||||
export const androidVersions: SelectOption[] = [
|
||||
{ value: "1.5", label: "Android Cupcake" },
|
||||
{ value: "1.6", label: "Android Donut" },
|
||||
{ value: "2.0", label: "Android Eclair" },
|
||||
{ value: "2.2", label: "Android Froyo" },
|
||||
{ value: "2.3", label: "Android Gingerbread" },
|
||||
{ value: "3.0", label: "Android Honeycomb" },
|
||||
{ value: "4.0", label: "Android Ice Cream Sandwich" },
|
||||
{ value: "4.1", label: "Android Jelly Bean" },
|
||||
{ value: "4.4", label: "Android KitKat" },
|
||||
{ value: "5.0", label: "Android Lollipop" },
|
||||
{ value: "6.0", label: "Android Marshmallow" },
|
||||
{ value: "7.0", label: "Android Nougat" },
|
||||
{ value: "8.0", label: "Android Oreo" },
|
||||
{ value: "9.0", label: "Android Pie" },
|
||||
{ value: "10", label: "Android 10" },
|
||||
@@ -140,4 +101,5 @@ export const androidVersions: SelectOption[] = [
|
||||
{ value: "13", label: "Android 13" },
|
||||
{ value: "14", label: "Android 14" },
|
||||
{ value: "15", label: "Android 15" },
|
||||
{ value: "16", label: "Android 16" },
|
||||
];
|
||||
|
||||
@@ -16,4 +16,5 @@ export interface SetupKey {
|
||||
expires_in: number;
|
||||
usage_limit: number | null;
|
||||
ephemeral: boolean;
|
||||
allow_extra_dns_labels: boolean;
|
||||
}
|
||||
|
||||
@@ -149,6 +149,8 @@ export function AccessControlModalContent({
|
||||
submit,
|
||||
isPostureChecksLoading,
|
||||
getPolicyData,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
} = useAccessControl({
|
||||
policy,
|
||||
postureCheckTemplates,
|
||||
@@ -166,9 +168,10 @@ export function AccessControlModalContent({
|
||||
});
|
||||
|
||||
const continuePostureChecksDisabled = useMemo(() => {
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
if (direction != "bi" && ports.length == 0) return true;
|
||||
}, [sourceGroups, destinationGroups, direction, ports]);
|
||||
if (sourceGroups.length > 0 && destinationResource) return false;
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
}, [sourceGroups, destinationGroups, direction, ports, destinationResource]);
|
||||
|
||||
const submitDisabled = useMemo(() => {
|
||||
if (name.length == 0) return true;
|
||||
@@ -281,6 +284,7 @@ export function AccessControlModalContent({
|
||||
onChange={setSourceGroups}
|
||||
values={sourceGroups}
|
||||
saveGroupAssignments={useSave}
|
||||
showResourceCounter={false}
|
||||
/>
|
||||
</div>
|
||||
<PolicyDirection
|
||||
@@ -303,6 +307,10 @@ export function AccessControlModalContent({
|
||||
onChange={setDestinationGroups}
|
||||
values={destinationGroups}
|
||||
saveGroupAssignments={useSave}
|
||||
resource={destinationResource}
|
||||
onResourceChange={setDestinationResource}
|
||||
showResources={true}
|
||||
placeholder={"Select destination(s)..."}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,9 +399,7 @@ export function AccessControlModalContent({
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/manage-network-access"
|
||||
}
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Access Controls
|
||||
|
||||
@@ -32,6 +32,9 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
|
||||
return group.id;
|
||||
}) as string[])
|
||||
: [];
|
||||
if (rule.destinationResource) {
|
||||
rule.destinations = null;
|
||||
}
|
||||
});
|
||||
|
||||
updatePolicy(
|
||||
|
||||
@@ -1,18 +1,49 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import ResourceBadge from "@components/ui/ResourceBadge";
|
||||
import useFetchApi from "@utils/api";
|
||||
import React, { useMemo } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
export default function AccessControlDestinationsCell({ policy }: Props) {
|
||||
export default function AccessControlDestinationsCell({
|
||||
policy,
|
||||
}: Readonly<Props>) {
|
||||
const firstRule = useMemo(() => {
|
||||
if (policy.rules.length > 0) return policy.rules[0];
|
||||
return undefined;
|
||||
}, [policy]);
|
||||
|
||||
if (firstRule?.destinationResource) {
|
||||
return (
|
||||
<AccessControlDestinationResourceCell
|
||||
resource={firstRule.destinationResource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.destinations as Group[]} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
const AccessControlDestinationResourceCell = ({
|
||||
resource,
|
||||
}: {
|
||||
resource: PolicyRuleResource;
|
||||
}) => {
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
if (isLoading) return <Skeleton height={35} width={"50%"} />;
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ResourceBadge resource={resources?.find((r) => r.id === resource.id)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -117,6 +117,10 @@ export const useAccessControl = ({
|
||||
: initialDestinationGroups ?? [],
|
||||
});
|
||||
|
||||
const [destinationResource, setDestinationResource] = useState(
|
||||
firstRule?.destinationResource,
|
||||
);
|
||||
|
||||
const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({});
|
||||
const createPostureChecksWithoutID = async () => {
|
||||
const checks = postureChecks.filter(
|
||||
@@ -146,7 +150,8 @@ export const useAccessControl = ({
|
||||
description,
|
||||
name,
|
||||
sources: sources,
|
||||
destinations: destinations,
|
||||
destinations: destinationResource ? undefined : destinations,
|
||||
destinationResource: destinationResource || undefined,
|
||||
action: "accept",
|
||||
protocol,
|
||||
enabled,
|
||||
@@ -214,7 +219,8 @@ export const useAccessControl = ({
|
||||
protocol,
|
||||
enabled,
|
||||
sources,
|
||||
destinations,
|
||||
destinations: destinationResource ? undefined : destinations,
|
||||
destinationResource: destinationResource || undefined,
|
||||
ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined,
|
||||
},
|
||||
],
|
||||
@@ -268,5 +274,7 @@ export const useAccessControl = ({
|
||||
getPolicyData,
|
||||
portAndDirectionDisabled,
|
||||
isPostureChecksLoading,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -37,6 +37,22 @@ const ActivityFeedColumnsTable: ColumnDef<ActivityEvent>[] = [
|
||||
filterFn: "arrIncludesSomeExact",
|
||||
cell: ({ row }) => <ActivityEntryRow event={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "activity_text",
|
||||
accessorFn: (event) => {
|
||||
try {
|
||||
if (event.meta) {
|
||||
return Object.keys(event.meta)
|
||||
.map((key) => {
|
||||
return `${event?.meta[key]}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
id: "timestamp",
|
||||
@@ -103,6 +119,7 @@ export default function ActivityTable({
|
||||
columnVisibility={{
|
||||
timestamp: false,
|
||||
name: false,
|
||||
activity_text: false,
|
||||
initiator_email: false,
|
||||
}}
|
||||
getStartedCard={
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function LastTimeRow({
|
||||
<TooltipTrigger>
|
||||
<div
|
||||
className={
|
||||
"flex items-center whitespace-nowrap gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md"
|
||||
"flex items-center whitespace-nowrap gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-default"
|
||||
}
|
||||
>
|
||||
<>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { DropdownMenuItem } from "@components/DropdownMenu";
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
|
||||
import { RouteModalContent } from "@/modules/routes/RouteModal";
|
||||
@@ -16,10 +14,9 @@ type Props = {
|
||||
|
||||
export const ExitNodeDropdownButton = ({ peer }: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const isLinux = getOperatingSystem(peer.os) === OperatingSystem.LINUX;
|
||||
const hasExitNodes = useHasExitNodes(peer);
|
||||
|
||||
return isLinux ? (
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => setModal(true)}>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
@@ -55,5 +52,5 @@ export const ExitNodeDropdownButton = ({ peer }: Props) => {
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
@@ -370,6 +370,6 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>OS</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <PeerOSCell os={row.original.os} />,
|
||||
cell: ({ row }) => <PeerOSCell os={row.original.os} serial={row.original.serial_number} />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Checkbox } from "@components/Checkbox";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
@@ -171,10 +172,13 @@ export function GroupSelector({
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 whitespace-nowrap text-sm"
|
||||
"flex items-center gap-2 whitespace-nowrap text-sm font-normal"
|
||||
}
|
||||
>
|
||||
<FolderGit2 size={13} className={"shrink-0"} />
|
||||
<GroupBadgeIcon
|
||||
id={item?.id}
|
||||
issued={item?.issued}
|
||||
/>
|
||||
<TextWithTooltip text={value} maxChars={15} />
|
||||
</div>
|
||||
<div
|
||||
|
||||
24
src/modules/groups/useGroupIdentification.ts
Normal file
24
src/modules/groups/useGroupIdentification.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { GroupIssued } from "@/interfaces/Group";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
issued?: string;
|
||||
};
|
||||
|
||||
export const useGroupIdentification = ({ id, issued }: Props) => {
|
||||
const isJWTGroup = issued === GroupIssued.JWT;
|
||||
const isOktaGroup = !!id?.includes("okta");
|
||||
const isGoogleGroup = !!id?.includes("google");
|
||||
const isAzureGroup = !!id?.includes("azure");
|
||||
|
||||
const isRegularGroup =
|
||||
!isJWTGroup && !isOktaGroup && !isGoogleGroup && !isAzureGroup;
|
||||
|
||||
return {
|
||||
isOktaGroup,
|
||||
isGoogleGroup,
|
||||
isAzureGroup,
|
||||
isJWTGroup,
|
||||
isRegularGroup,
|
||||
};
|
||||
};
|
||||
@@ -97,7 +97,7 @@ const Content = ({ network, onCreated, onUpdated }: ContentProps) => {
|
||||
description={
|
||||
network
|
||||
? network.name
|
||||
: "Access resources like LANs and VPC by adding a network."
|
||||
: "Access internal resources in LANs and VPC by adding a network."
|
||||
}
|
||||
color={"netbird"}
|
||||
/>
|
||||
@@ -131,7 +131,10 @@ const Content = ({ network, onCreated, onUpdated }: ContentProps) => {
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink href={"https://docs.netbird.io/how-to/networks"} target={"_blank"}>
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Networks
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
import NetworkModal from "@/modules/networks/NetworkModal";
|
||||
import NetworkResourceModal from "@/modules/networks/resources/NetworkResourceModal";
|
||||
import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupModal";
|
||||
import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal";
|
||||
|
||||
type Props = {
|
||||
@@ -23,6 +24,10 @@ const NetworksContext = React.createContext(
|
||||
openEditNetworkModal: (network: Network) => void;
|
||||
openCreateNetworkModal: () => void;
|
||||
openResourceModal: (network: Network, resource?: NetworkResource) => void;
|
||||
openResourceGroupModal: (
|
||||
network: Network,
|
||||
resource?: NetworkResource,
|
||||
) => void;
|
||||
openPolicyModal: (network?: Network, resource?: NetworkResource) => void;
|
||||
deleteNetwork: (network: Network) => void;
|
||||
deleteResource: (network: Network, resource: NetworkResource) => void;
|
||||
@@ -49,6 +54,7 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
const [routingPeerModal, setRoutingPeerModal] = useState(false);
|
||||
const [networkModal, setNetworkModal] = useState(false);
|
||||
const [resourceModal, setResourceModal] = useState(false);
|
||||
const [resourceGroupModal, setResourceGroupModal] = useState(false);
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
|
||||
const openAddRoutingPeerModal = (
|
||||
@@ -76,6 +82,15 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
setResourceModal(true);
|
||||
};
|
||||
|
||||
const openResourceGroupModal = (
|
||||
network: Network,
|
||||
resource?: NetworkResource,
|
||||
) => {
|
||||
setCurrentNetwork(network);
|
||||
resource && setCurrentResource(resource);
|
||||
setResourceGroupModal(true);
|
||||
};
|
||||
|
||||
const openPolicyModal = (network?: Network, resource?: NetworkResource) => {
|
||||
setPolicyDefaultSettings({
|
||||
destinationGroups: resource?.groups,
|
||||
@@ -217,6 +232,7 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
openEditNetworkModal,
|
||||
openCreateNetworkModal,
|
||||
openResourceModal,
|
||||
openResourceGroupModal,
|
||||
openPolicyModal,
|
||||
deleteNetwork,
|
||||
deleteResource,
|
||||
@@ -232,7 +248,7 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
network={currentNetwork}
|
||||
onCreated={async (network) => {
|
||||
mutate("/networks");
|
||||
await askForRoutingPeer(network);
|
||||
await askForResource(network);
|
||||
}}
|
||||
onUpdated={() => {
|
||||
mutate("/networks");
|
||||
@@ -250,13 +266,15 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
initialDestinationGroups={policyDefaultSettings?.destinationGroups}
|
||||
initialName={policyDefaultSettings?.name}
|
||||
initialDescription={policyDefaultSettings?.description}
|
||||
onSuccess={(p) => {
|
||||
onSuccess={async (p) => {
|
||||
setPolicyModal(false);
|
||||
setPolicyDefaultSettings(undefined);
|
||||
mutate("/networks");
|
||||
if (network) {
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
} else {
|
||||
currentNetwork && (await askForRoutingPeer(currentNetwork));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -275,8 +293,6 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
if (network) {
|
||||
mutate(`/networks/${currentNetwork.id}/routers`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
} else {
|
||||
await askForResource(currentNetwork);
|
||||
}
|
||||
}}
|
||||
onUpdated={async () => {
|
||||
@@ -294,6 +310,26 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
setRoutingPeerModal(state);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResourceGroupModal
|
||||
network={currentNetwork}
|
||||
resource={currentResource}
|
||||
open={resourceGroupModal}
|
||||
onOpenChange={(state) => {
|
||||
setCurrentResource(undefined);
|
||||
setResourceGroupModal(state);
|
||||
}}
|
||||
onUpdated={() => {
|
||||
setResourceGroupModal(false);
|
||||
setCurrentResource(undefined);
|
||||
mutate("/groups");
|
||||
if (network) {
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<NetworkResourceModal
|
||||
network={currentNetwork}
|
||||
resource={currentResource}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import DescriptionWithTooltip from "@components/ui/DescriptionWithTooltip";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -51,18 +51,18 @@ export const NetworkInformationSquare = ({
|
||||
></div>
|
||||
</div>
|
||||
<div className={"mt-[0px] flex items-center flex-wrap"}>
|
||||
<p
|
||||
<TruncatedText
|
||||
className={cn(
|
||||
"font-medium",
|
||||
"font-medium text-white text-left",
|
||||
size == "md" ? "text-sm" : "text-xl leading-none mb-0.5",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
<DescriptionWithTooltip
|
||||
maxChars={24}
|
||||
text={name}
|
||||
/>
|
||||
<TruncatedText
|
||||
className={cn(
|
||||
"text-left",
|
||||
size == "lg" && "text-md leading-none mt-0.5",
|
||||
"text-left text-sm text-nb-gray-400",
|
||||
size == "lg" && "text-md mt-0.5",
|
||||
)}
|
||||
maxChars={24}
|
||||
text={description}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import SidebarItem from "@components/SidebarItem";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import * as React from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { NetworkRoutesDeprecationInfo } from "@/modules/networks/misc/NetworkRoutesDeprecationInfo";
|
||||
|
||||
export const NetworkNavigation = () => {
|
||||
return (
|
||||
<SidebarItem
|
||||
icon={<NetworkRoutesIcon />}
|
||||
label="Networks"
|
||||
collapsible
|
||||
exactPathMatch={false}
|
||||
>
|
||||
<SidebarItem label="Networks" isChild href={"/networks"} />
|
||||
<>
|
||||
<SidebarItem
|
||||
icon={<NetworkRoutesIcon />}
|
||||
label={
|
||||
<div className={"flex items-center"}>
|
||||
Network Routes
|
||||
<NetworkRoutesDeprecationInfo />
|
||||
<div className={"flex items-center gap-2"}>
|
||||
Networks
|
||||
<SmallBadge />
|
||||
</div>
|
||||
}
|
||||
isChild
|
||||
href={"/network-routes"}
|
||||
href={"/networks"}
|
||||
/>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
icon={<NetworkRoutesIcon />}
|
||||
href={"/network-routes"}
|
||||
label={"Network Routes"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
@@ -16,8 +17,14 @@ import { notify } from "@components/Notification";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { Textarea } from "@components/Textarea";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { ExternalLinkIcon, PlusCircle, WorkflowIcon } from "lucide-react";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
PlusCircle,
|
||||
Power,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
@@ -79,6 +86,9 @@ export function ResourceModalContent({
|
||||
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
|
||||
initial: resource?.groups || [],
|
||||
});
|
||||
const [enabled, setEnabled] = useState<boolean>(
|
||||
resource ? resource.enabled : true,
|
||||
);
|
||||
|
||||
const createResource = async () => {
|
||||
const savedGroups = await saveGroups();
|
||||
@@ -91,6 +101,7 @@ export function ResourceModalContent({
|
||||
description,
|
||||
address,
|
||||
groups: savedGroups.map((g) => g.id),
|
||||
enabled,
|
||||
}).then((r) => {
|
||||
onCreated?.(r);
|
||||
}),
|
||||
@@ -108,6 +119,7 @@ export function ResourceModalContent({
|
||||
description,
|
||||
address,
|
||||
groups: savedGroups.map((g) => g.id),
|
||||
enabled,
|
||||
}).then((r) => {
|
||||
onUpdated?.(r);
|
||||
}),
|
||||
@@ -145,23 +157,52 @@ export function ResourceModalContent({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description (optional)</Label>
|
||||
<HelpText>
|
||||
Write a short description to add more context to this resource.
|
||||
</HelpText>
|
||||
<Textarea
|
||||
placeholder={"e.g., Production, Development"}
|
||||
value={description}
|
||||
rows={1}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ResourceSingleAddressInput value={address} onChange={setAddress} />
|
||||
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
<HelpText>
|
||||
Control access to this resource by assigning it to groups
|
||||
Add this resource to groups and use them as destinations when
|
||||
creating policies
|
||||
</HelpText>
|
||||
<PeerGroupSelector onChange={setGroups} values={groups} />
|
||||
</div>
|
||||
<div className={"mt-3"}>
|
||||
<FancyToggleSwitch
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
label={
|
||||
<>
|
||||
<Power size={15} />
|
||||
Enable Resource
|
||||
</>
|
||||
}
|
||||
helpText={"Use this switch to enable or disable the resource."}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink href={"https://docs.netbird.io/how-to/networks#resources"} target={"_blank"}>
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks#resources"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Resources
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import Button from "@components/Button";
|
||||
import { SquarePenIcon, Trash2 } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import { MoreVertical, SquarePenIcon, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
@@ -11,29 +17,45 @@ export const ResourceActionCell = ({ resource }: Props) => {
|
||||
const { deleteResource, network, openResourceModal } = useNetworksContext();
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<Button
|
||||
variant={"default-outline"}
|
||||
size={"sm"}
|
||||
onClick={() => {
|
||||
if (!network) return;
|
||||
openResourceModal(network, resource);
|
||||
}}
|
||||
>
|
||||
<SquarePenIcon size={16} />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant={"danger-outline"}
|
||||
size={"sm"}
|
||||
onClick={() => {
|
||||
if (!network) return;
|
||||
deleteResource(network, resource);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Remove
|
||||
</Button>
|
||||
<div className={"flex justify-end"}>
|
||||
<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={() => {
|
||||
if (!network) return;
|
||||
openResourceModal(network, resource);
|
||||
}}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<SquarePenIcon size={14} className={"shrink-0"} />
|
||||
Edit
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!network) return;
|
||||
deleteResource(network, resource);
|
||||
}}
|
||||
variant={"danger"}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<Trash2 size={14} className={"shrink-0"} />
|
||||
Remove
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
58
src/modules/networks/resources/ResourceEnabledCell.tsx
Normal file
58
src/modules/networks/resources/ResourceEnabledCell.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
|
||||
type Props = {
|
||||
resource: NetworkResource;
|
||||
};
|
||||
export const ResourceEnabledCell = ({ resource }: Props) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { network } = useNetworksContext();
|
||||
|
||||
const update = useApiCall<NetworkResource>(
|
||||
`/networks/${network?.id}/resources/${resource?.id}`,
|
||||
).put;
|
||||
|
||||
const toggle = async (enabled: boolean) => {
|
||||
notify({
|
||||
title: `Update Resource`,
|
||||
description: `'${resource?.name}' is now ${
|
||||
enabled ? "enabled" : "disabled"
|
||||
}`,
|
||||
loadingMessage: "Updating resource...",
|
||||
duration: 1200,
|
||||
promise: update({
|
||||
...resource,
|
||||
groups: resource.groups
|
||||
?.map((g) => {
|
||||
let group = g as Group;
|
||||
return group.id;
|
||||
})
|
||||
.filter((g) => g !== undefined),
|
||||
enabled,
|
||||
}).then(() => {
|
||||
mutate(`/networks/${network?.id}/resources`);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const isChecked = useMemo(() => {
|
||||
return resource.enabled;
|
||||
}, [resource]);
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ToggleSwitch
|
||||
checked={isChecked}
|
||||
size={"small"}
|
||||
onClick={() => toggle(!isChecked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,14 +2,23 @@ import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import * as React from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
|
||||
type Props = {
|
||||
resource?: NetworkResource;
|
||||
};
|
||||
export const ResourceGroupCell = ({ resource }: Props) => {
|
||||
const { network, openResourceGroupModal } = useNetworksContext();
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<button
|
||||
className={"flex cursor-pointer"}
|
||||
onClick={() => {
|
||||
if (!network) return;
|
||||
openResourceGroupModal(network, resource);
|
||||
}}
|
||||
>
|
||||
<MultipleGroups groups={resource?.groups as Group[]} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
121
src/modules/networks/resources/ResourceGroupModal.tsx
Normal file
121
src/modules/networks/resources/ResourceGroupModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { notify } from "@components/Notification";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
|
||||
type ResourceGroupModalProps = {
|
||||
resource?: NetworkResource;
|
||||
network?: Network;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpdated?: (r: NetworkResource) => void;
|
||||
};
|
||||
export const ResourceGroupModal = ({
|
||||
resource,
|
||||
network,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdated,
|
||||
}: ResourceGroupModalProps) => {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
{network && resource && (
|
||||
<ResourceGroupModalContent
|
||||
network={network}
|
||||
resource={resource}
|
||||
onUpdated={onUpdated}
|
||||
key={open ? "1" : "0"}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
type ModalProps = {
|
||||
onUpdated?: (r: NetworkResource) => void;
|
||||
network?: Network;
|
||||
resource?: NetworkResource;
|
||||
};
|
||||
|
||||
const ResourceGroupModalContent = ({
|
||||
resource,
|
||||
network,
|
||||
onUpdated,
|
||||
}: ModalProps) => {
|
||||
const update = useApiCall<NetworkResource>(
|
||||
`/networks/${network?.id}/resources/${resource?.id}`,
|
||||
).put;
|
||||
|
||||
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
|
||||
initial: resource?.groups || [],
|
||||
});
|
||||
|
||||
const updateResource = async () => {
|
||||
const savedGroups = await saveGroups();
|
||||
notify({
|
||||
title: "Update Resource",
|
||||
description: `'${resource?.name}' groups updated`,
|
||||
loadingMessage: "Updating resource groups...",
|
||||
promise: update({
|
||||
...resource,
|
||||
groups: savedGroups.map((g) => g.id),
|
||||
}).then((r) => {
|
||||
onUpdated?.(r);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
return groups.length > 0;
|
||||
}, [groups]);
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalHeader
|
||||
icon={<FolderGit2 size={18} />}
|
||||
title={"Assigned Groups"}
|
||||
description={
|
||||
"Add this resource to groups and use them as destinations when creating policies"
|
||||
}
|
||||
color={"blue"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-6 flex flex-col gap-8"}>
|
||||
<div>
|
||||
<PeerGroupSelector onChange={setGroups} values={groups} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={updateResource}
|
||||
disabled={!canSave}
|
||||
>
|
||||
Save Groups
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +1,53 @@
|
||||
import DescriptionWithTooltip from "@components/ui/DescriptionWithTooltip";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
|
||||
type Props = {
|
||||
resource: NetworkResource;
|
||||
};
|
||||
|
||||
export default function ResourceNameCell({ resource }: Readonly<Props>) {
|
||||
const { network, openResourceModal } = useNetworksContext();
|
||||
|
||||
return (
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
<button
|
||||
className={"flex gap-4 items-center group"}
|
||||
onClick={() => {
|
||||
if (!network) return;
|
||||
openResourceModal(network, resource);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-900 transition-all",
|
||||
"group-hover:bg-nb-gray-800",
|
||||
)}
|
||||
>
|
||||
{resource.type === "host" && <WorkflowIcon size={15} />}
|
||||
{resource.type === "domain" && <GlobeIcon size={15} />}
|
||||
{resource.type === "subnet" && <NetworkIcon size={15} />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate">
|
||||
<span className={"font-normal truncate"}>{resource.name}</span>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-0 text-neutral-300 font-light truncate",
|
||||
"group-hover:text-neutral-100 text-left",
|
||||
)}
|
||||
>
|
||||
<TextWithTooltip
|
||||
text={resource.name}
|
||||
maxChars={25}
|
||||
className={"font-normal"}
|
||||
/>
|
||||
<DescriptionWithTooltip
|
||||
maxChars={25}
|
||||
className={cn("font-normal mt-0.5 ")}
|
||||
text={resource.description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,15 +22,18 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
const resourceGroups = resource?.groups as Group[];
|
||||
return policies?.filter((policy) => {
|
||||
if (!policy.enabled) return false;
|
||||
const sourcePolicyGroups = policy.rules
|
||||
?.map((rule) => rule?.sources)
|
||||
.flat() as Group[];
|
||||
const destinationResource = policy.rules
|
||||
?.map((rule) => rule?.destinationResource?.id === resource?.id)
|
||||
.some((id) => id);
|
||||
if (destinationResource) return true;
|
||||
const destinationPolicyGroups = policy.rules
|
||||
?.map((rule) => rule?.destinations)
|
||||
.flat() as Group[];
|
||||
const policyGroups = [...sourcePolicyGroups, ...destinationPolicyGroups];
|
||||
const policyGroups = [...destinationPolicyGroups];
|
||||
return resourceGroups.some((resourceGroup) =>
|
||||
policyGroups.some((policyGroup) => policyGroup.id === resourceGroup.id),
|
||||
policyGroups.some(
|
||||
(policyGroup) => policyGroup?.id === resourceGroup.id,
|
||||
),
|
||||
);
|
||||
});
|
||||
}, [policies, resource]);
|
||||
@@ -89,7 +92,7 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
className={"min-w-[110px]"}
|
||||
className={"min-w-[100px]"}
|
||||
onClick={() => openPolicyModal(network, resource)}
|
||||
>
|
||||
<PlusCircle size={12} />
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import Button from "@components/Button";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable, {
|
||||
SkeletonTableHeader,
|
||||
} from "@components/skeletons/SkeletonTable";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { IconCirclePlus } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import * as React from "react";
|
||||
import { Suspense } from "react";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
import ResourcesTable from "@/modules/networks/resources/ResourcesTable";
|
||||
|
||||
type ResourcesSectionProps = {
|
||||
@@ -23,27 +20,14 @@ export const ResourcesSection = ({ network }: ResourcesSectionProps) => {
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
const { openResourceModal } = useNetworksContext();
|
||||
|
||||
return (
|
||||
<div className={"py-7 px-8"}>
|
||||
<div className={"max-w-6xl"}>
|
||||
<div className={"flex justify-between items-center"}>
|
||||
<div className={"flex justify-between items-center mb-6"}>
|
||||
<div>
|
||||
<h2 ref={headingRef}>Resources</h2>
|
||||
<Paragraph>Add and manage resources for this network.</Paragraph>
|
||||
</div>
|
||||
<div className={"inline-flex gap-4 justify-end"}>
|
||||
<div>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => openResourceModal(network)}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Add Resource
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import Button from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { IconCirclePlus } from "@tabler/icons-react";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { Layers3Icon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
import { ResourceActionCell } from "@/modules/networks/resources/ResourceActionCell";
|
||||
import ResourceAddressCell from "@/modules/networks/resources/ResourceAddressCell";
|
||||
import { ResourceEnabledCell } from "@/modules/networks/resources/ResourceEnabledCell";
|
||||
import { ResourceGroupCell } from "@/modules/networks/resources/ResourceGroupCell";
|
||||
import ResourceNameCell from "@/modules/networks/resources/ResourceNameCell";
|
||||
import { ResourcePolicyCell } from "@/modules/networks/resources/ResourcePolicyCell";
|
||||
@@ -22,7 +29,7 @@ type Props = {
|
||||
const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
|
||||
{
|
||||
id: "id",
|
||||
accessorKey: "id",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Resource</DataTableHeader>;
|
||||
},
|
||||
@@ -30,6 +37,12 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
|
||||
return <ResourceNameCell resource={row.original} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
accessorFn: (resource) =>
|
||||
removeAllSpaces(resource?.description || "").toLowerCase(),
|
||||
},
|
||||
{
|
||||
id: "address",
|
||||
accessorKey: "address",
|
||||
@@ -40,9 +53,20 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
|
||||
return <ResourceAddressCell resource={row.original} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Active</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <ResourceEnabledCell resource={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "groups",
|
||||
accessorKey: "id",
|
||||
accessorFn: (resource) => {
|
||||
let groups = resource?.groups as Group[];
|
||||
return groups.map((group) => group.name).join(", ");
|
||||
},
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Groups</DataTableHeader>;
|
||||
},
|
||||
@@ -74,40 +98,58 @@ export default function ResourcesTable({
|
||||
resources,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const { openResourceModal, network } = useNetworksContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 w-full" }}
|
||||
headingTarget={headingTarget}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
showSearchAndFilters={false}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Peers"}
|
||||
columns={NetworkResourceColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={resources}
|
||||
searchPlaceholder={"Search by name, IP, owner or group..."}
|
||||
isLoading={isLoading}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This network has no resources"}
|
||||
description={
|
||||
"Add resources to this network to control what peers can access. Resources can be anything from a single IP, a subnet, or a domain."
|
||||
}
|
||||
icon={<Layers3Icon size={20} />}
|
||||
/>
|
||||
}
|
||||
columnVisibility={{}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
/>
|
||||
</>
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 pb-2 w-full" }}
|
||||
headingTarget={headingTarget}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
showSearchAndFilters={true}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Resources"}
|
||||
columns={NetworkResourceColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={resources}
|
||||
searchPlaceholder={"Search by name, address or group..."}
|
||||
isLoading={isLoading}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This network has no resources"}
|
||||
description={
|
||||
"Add resources to this network to control what peers can access. Resources can be anything from a single IP, a subnet, or a domain."
|
||||
}
|
||||
icon={<Layers3Icon size={20} />}
|
||||
/>
|
||||
}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
rightSide={() => (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"ml-auto"}
|
||||
onClick={() => network && openResourceModal(network)}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Add Resource
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{(table) => (
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={!resources || resources?.length == 0}
|
||||
/>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,23 +19,32 @@ import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import { PeerSelector } from "@components/PeerSelector";
|
||||
import { SegmentedTabs } from "@components/SegmentedTabs";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { uniqBy } from "lodash";
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
FolderGit2,
|
||||
Loader2,
|
||||
MonitorSmartphoneIcon,
|
||||
PlusCircle,
|
||||
Power,
|
||||
Settings2,
|
||||
Share2Icon,
|
||||
VenetianMask,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { Network, NetworkRouter } from "@/interfaces/Network";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { RoutingPeerMasqueradeSwitch } from "@/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
network: Network;
|
||||
@@ -110,12 +119,25 @@ function RoutingPeerModalContent({
|
||||
});
|
||||
|
||||
const [masquerade, setMasquerade] = useState<boolean>(
|
||||
router?.masquerade || true,
|
||||
router ? router.masquerade : true,
|
||||
);
|
||||
const [enabled, setEnabled] = useState<boolean>(
|
||||
router ? router.enabled : true,
|
||||
);
|
||||
|
||||
const [metric, setMetric] = useState(
|
||||
router?.metric ? router.metric.toString() : "9999",
|
||||
);
|
||||
|
||||
const isNonLinuxRoutingPeer = useMemo(() => {
|
||||
if (!routingPeer) return false;
|
||||
return getOperatingSystem(routingPeer.os) != OperatingSystem.LINUX;
|
||||
}, [routingPeer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNonLinuxRoutingPeer) setMasquerade(true);
|
||||
}, [isNonLinuxRoutingPeer]);
|
||||
|
||||
const addRouter = async () => {
|
||||
// Create groups that do not exist
|
||||
const g1 = getAllRoutingGroupsToUpdate();
|
||||
@@ -137,7 +159,8 @@ function RoutingPeerModalContent({
|
||||
? createdGroups.map((g) => g.id)
|
||||
: undefined,
|
||||
metric: parseInt(metric),
|
||||
masquerade,
|
||||
enabled,
|
||||
masquerade: isRoutingPeer && isNonLinuxRoutingPeer ? true : masquerade,
|
||||
}).then((r) => {
|
||||
onCreated?.(r);
|
||||
}),
|
||||
@@ -165,13 +188,16 @@ function RoutingPeerModalContent({
|
||||
? createdGroups.map((g) => g.id)
|
||||
: undefined,
|
||||
metric: parseInt(metric),
|
||||
masquerade,
|
||||
enabled,
|
||||
masquerade: isRoutingPeer && isNonLinuxRoutingPeer ? true : masquerade,
|
||||
}).then((r) => {
|
||||
onUpdated?.(r);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const canContinue = routingPeer !== undefined || routingPeerGroups.length > 0;
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalHeader
|
||||
@@ -190,7 +216,7 @@ function RoutingPeerModalContent({
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
Routers
|
||||
Routing Peers
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger value={"settings"} className={"ml-auto"}>
|
||||
@@ -203,62 +229,93 @@ function RoutingPeerModalContent({
|
||||
Advanced Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value={"router"} className={"pb-8"}>
|
||||
<div className={"flex flex-col gap-4 px-8 "}>
|
||||
<SegmentedTabs value={type} onChange={setType}>
|
||||
<SegmentedTabs.List>
|
||||
<SegmentedTabs.Trigger value={"peer"}>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
Routing Peers
|
||||
</SegmentedTabs.Trigger>
|
||||
<TabsContent value={"router"} className={"pb-6"}>
|
||||
<div className={"flex flex-col gap-4 px-8"}>
|
||||
<div className={"relative "}>
|
||||
<SegmentedTabs
|
||||
value={type}
|
||||
onChange={(state) => {
|
||||
setType(state);
|
||||
setRoutingPeer(undefined);
|
||||
setRoutingPeerGroups([]);
|
||||
}}
|
||||
>
|
||||
<SegmentedTabs.List>
|
||||
<SegmentedTabs.Trigger value={"peer"}>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
Routing Peers
|
||||
</SegmentedTabs.Trigger>
|
||||
|
||||
<SegmentedTabs.Trigger value={"group"}>
|
||||
<FolderGit2 size={16} />
|
||||
Peer Group
|
||||
</SegmentedTabs.Trigger>
|
||||
</SegmentedTabs.List>
|
||||
<SegmentedTabs.Content value={"peer"}>
|
||||
<div>
|
||||
<HelpText>
|
||||
Assign a single or multiple peers as a routing peers for the
|
||||
network.
|
||||
</HelpText>
|
||||
<PeerSelector onChange={setRoutingPeer} value={routingPeer} />
|
||||
</div>
|
||||
</SegmentedTabs.Content>
|
||||
<SegmentedTabs.Content value={"group"}>
|
||||
<div>
|
||||
<HelpText>
|
||||
Assign a peer group with Linux machines to be used as
|
||||
routing peers.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
max={1}
|
||||
onChange={setRoutingPeerGroups}
|
||||
values={routingPeerGroups}
|
||||
/>
|
||||
</div>
|
||||
</SegmentedTabs.Content>
|
||||
</SegmentedTabs>
|
||||
<SegmentedTabs.Trigger value={"group"}>
|
||||
<FolderGit2 size={16} />
|
||||
Peer Group
|
||||
</SegmentedTabs.Trigger>
|
||||
</SegmentedTabs.List>
|
||||
<SegmentedTabs.Content value={"peer"}>
|
||||
<div>
|
||||
<HelpText>
|
||||
Assign a single or multiple peers as routing peers for the
|
||||
network.
|
||||
</HelpText>
|
||||
<PeerSelector
|
||||
onChange={setRoutingPeer}
|
||||
value={routingPeer}
|
||||
/>
|
||||
</div>
|
||||
</SegmentedTabs.Content>
|
||||
<SegmentedTabs.Content value={"group"}>
|
||||
<div>
|
||||
<HelpText>
|
||||
Assign a peer group with machines to be used as routing
|
||||
peers.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
max={1}
|
||||
onChange={setRoutingPeerGroups}
|
||||
values={routingPeerGroups}
|
||||
/>
|
||||
</div>
|
||||
</SegmentedTabs.Content>
|
||||
</SegmentedTabs>
|
||||
</div>
|
||||
|
||||
<div className={cn("flex justify-between items-center mt-3")}>
|
||||
<div>
|
||||
<Label>{"Don't have a routing peer?"}</Label>
|
||||
<HelpText className={""}>
|
||||
You can install NetBird with a setup key on one or more
|
||||
machines to act as routing peers.
|
||||
</HelpText>
|
||||
</div>
|
||||
<InstallNetBirdWithSetupKeyButton
|
||||
name={`Routing Peer (${network.name})`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"settings"} className={"pb-4"}>
|
||||
<div className={"px-8 flex flex-col gap-6"}>
|
||||
<FancyToggleSwitch
|
||||
value={masquerade}
|
||||
onChange={setMasquerade}
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
label={
|
||||
<>
|
||||
<VenetianMask size={15} />
|
||||
Masquerade
|
||||
<Power size={15} />
|
||||
Enable Routing Peer
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Allow access to your private networks without configuring routes on your local routers or other devices."
|
||||
"Use this switch to enable or disable the routing peer."
|
||||
}
|
||||
/>
|
||||
|
||||
<RoutingPeerMasqueradeSwitch
|
||||
value={masquerade}
|
||||
onChange={setMasquerade}
|
||||
disabled={isNonLinuxRoutingPeer}
|
||||
/>
|
||||
|
||||
<div className={cn("flex justify-between")}>
|
||||
<div>
|
||||
<Label>Metric</Label>
|
||||
@@ -293,9 +350,7 @@ function RoutingPeerModalContent({
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/networks#routing-peers"
|
||||
}
|
||||
href={"https://docs.netbird.io/how-to/networks#routing-peers"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Routing Peers
|
||||
@@ -304,34 +359,129 @@ function RoutingPeerModalContent({
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
{tab == "router" && (
|
||||
<Button variant={"primary"} onClick={() => setTab("settings")}>
|
||||
Continue
|
||||
</Button>
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("settings")}
|
||||
disabled={!canContinue}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{tab == "settings" && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={
|
||||
routingPeer == undefined && routingPeerGroups.length <= 0
|
||||
}
|
||||
onClick={router ? updateRouter : addRouter}
|
||||
>
|
||||
{router ? (
|
||||
<>Save Changes</>
|
||||
) : (
|
||||
<>
|
||||
<PlusCircle size={16} />
|
||||
Add Routing Peer
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<>
|
||||
<Button variant={"secondary"} onClick={() => setTab("router")}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={
|
||||
routingPeer == undefined && routingPeerGroups.length <= 0
|
||||
}
|
||||
onClick={router ? updateRouter : addRouter}
|
||||
>
|
||||
{router ? (
|
||||
<>Save Changes</>
|
||||
) : (
|
||||
<>
|
||||
<PlusCircle size={16} />
|
||||
Add Routing Peer
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
type InstallNetBirdWithSetupKeyButtonProps = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const InstallNetBirdWithSetupKeyButton = ({
|
||||
name,
|
||||
}: InstallNetBirdWithSetupKeyButtonProps) => {
|
||||
const setupKeyRequest = useApiCall<SetupKey>("/setup-keys", true);
|
||||
const { mutate } = useSWRConfig();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
const [installModal, setInstallModal] = useState(false);
|
||||
const [setupKey, setSetupKey] = useState<SetupKey>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const createSetupKey = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Create a Setup Key?`,
|
||||
description:
|
||||
"If you continue, a one-off setup key will be automatically created and you will be able to install NetBird.",
|
||||
confirmText: "Continue",
|
||||
cancelText: "Cancel",
|
||||
type: "default",
|
||||
});
|
||||
if (!choice) return;
|
||||
|
||||
const loadingTimeout = setTimeout(() => setIsLoading(true), 1000);
|
||||
|
||||
await setupKeyRequest
|
||||
.post({
|
||||
name,
|
||||
type: "one-off",
|
||||
expires_in: 24 * 60 * 60, // 1 day expiration
|
||||
revoked: false,
|
||||
auto_groups: [],
|
||||
usage_limit: 1,
|
||||
ephemeral: false,
|
||||
allow_extra_dns_labels: false,
|
||||
})
|
||||
.then((setupKey) => {
|
||||
setInstallModal(true);
|
||||
setSetupKey(setupKey);
|
||||
mutate("/setup-keys");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
clearTimeout(loadingTimeout);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
className={"ml-8"}
|
||||
onClick={createSetupKey}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={14} className={"animate-spin delay-1000"} />
|
||||
) : (
|
||||
<DownloadIcon size={14} />
|
||||
)}
|
||||
Install NetBird
|
||||
</Button>
|
||||
{setupKey && (
|
||||
<Modal
|
||||
open={installModal}
|
||||
onOpenChange={setInstallModal}
|
||||
key={setupKey.key}
|
||||
>
|
||||
<SetupModal
|
||||
showClose={true}
|
||||
setupKey={setupKey.key}
|
||||
showOnlyRoutingPeerOS={true}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,13 +44,11 @@ export const NetworkRoutingPeerName = ({ router }: Props) => {
|
||||
|
||||
if (routingPeerGroup) {
|
||||
return (
|
||||
<>
|
||||
<div className={"flex items-center gap-2 max-w-[295px] min-w-[295px]"}>
|
||||
<GroupBadge group={routingPeerGroup} />
|
||||
<ArrowRightIcon size={14} className={"shrink-0"} />
|
||||
<PeerBadge> {routingPeerGroup.peers_count} Peer(s)</PeerBadge>
|
||||
</div>
|
||||
</>
|
||||
<div className={"flex items-center gap-2 max-w-[295px] min-w-[295px]"}>
|
||||
<GroupBadge group={routingPeerGroup} />
|
||||
<ArrowRightIcon size={14} className={"shrink-0"} />
|
||||
<PeerBadge> {routingPeerGroup.peers_count} Peer(s)</PeerBadge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import Button from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { IconCirclePlus } from "@tabler/icons-react";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import { NetworkRouter } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
import { NetworkRoutingPeerName } from "@/modules/networks/routing-peers/NetworkRoutingPeerName";
|
||||
import { RoutingPeersActionCell } from "@/modules/networks/routing-peers/RoutingPeersActionCell";
|
||||
import { RoutingPeersEnabledCell } from "@/modules/networks/routing-peers/RoutingPeersEnabledCell";
|
||||
import { RoutingPeersMasqueradeCell } from "@/modules/networks/routing-peers/RoutingPeersMasqueradeCell";
|
||||
import RouteMetricCell from "@/modules/routes/RouteMetricCell";
|
||||
|
||||
@@ -28,13 +33,23 @@ const NetworkRouterColumns: ColumnDef<NetworkRouter>[] = [
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <NetworkRoutingPeerName router={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Active</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <RoutingPeersEnabledCell router={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "metric",
|
||||
accessorKey: "metric",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Metric</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <RouteMetricCell metric={row.original.metric} />,
|
||||
cell: ({ row }) => (
|
||||
<RouteMetricCell metric={row.original.metric} useHoverStyle={false} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "masquerade",
|
||||
@@ -58,7 +73,9 @@ export default function NetworkRoutingPeersTable({
|
||||
routers,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
const { openAddRoutingPeerModal, network } = useNetworksContext();
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "metric",
|
||||
@@ -69,7 +86,7 @@ export default function NetworkRoutingPeersTable({
|
||||
return (
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 w-full" }}
|
||||
wrapperProps={{ className: "mt-6 pb-2 w-full" }}
|
||||
headingTarget={headingTarget}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
@@ -77,11 +94,11 @@ export default function NetworkRoutingPeersTable({
|
||||
showSearchAndFilters={false}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Peers"}
|
||||
text={"Routing Peers"}
|
||||
columns={NetworkRouterColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={routers}
|
||||
searchPlaceholder={"Search by name, IP, owner or group..."}
|
||||
searchPlaceholder={"Search by name..."}
|
||||
isLoading={isLoading}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
@@ -95,6 +112,23 @@ export default function NetworkRoutingPeersTable({
|
||||
}
|
||||
columnVisibility={{}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
/>
|
||||
rightSide={() => (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"ml-auto"}
|
||||
onClick={() => network && openAddRoutingPeerModal(network)}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Add Routing Peer
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{(table) => (
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={!routers || routers?.length == 0}
|
||||
/>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { VenetianMask } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export const RoutingPeerMasqueradeSwitch = ({
|
||||
disabled = false,
|
||||
value,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
return (
|
||||
<RoutingPeerMasqueradeTooltip show={disabled}>
|
||||
<FancyToggleSwitch
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
label={
|
||||
<>
|
||||
<VenetianMask size={15} />
|
||||
Masquerade
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Allow access to your private networks without configuring routes on your local routers or other devices."
|
||||
}
|
||||
/>
|
||||
</RoutingPeerMasqueradeTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
type RoutingPeerMasqueradeTooltipProps = {
|
||||
show?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const RoutingPeerMasqueradeTooltip = ({
|
||||
show = false,
|
||||
children,
|
||||
}: RoutingPeerMasqueradeTooltipProps) => {
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs"}>
|
||||
Masquerade needs to be enabled for non-Linux routing peers.
|
||||
</div>
|
||||
}
|
||||
delayDuration={250}
|
||||
skipDelayDuration={350}
|
||||
disabled={!show}
|
||||
className={"cursor-help"}
|
||||
>
|
||||
{children}
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,11 @@
|
||||
import Button from "@components/Button";
|
||||
import { SquarePenIcon, Trash2 } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import { MoreVertical, SquarePenIcon, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { NetworkRouter } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
@@ -12,29 +18,45 @@ export const RoutingPeersActionCell = ({ router }: Props) => {
|
||||
useNetworksContext();
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<Button
|
||||
variant={"default-outline"}
|
||||
size={"sm"}
|
||||
onClick={() => {
|
||||
if (!network) return;
|
||||
openAddRoutingPeerModal(network, router);
|
||||
}}
|
||||
>
|
||||
<SquarePenIcon size={16} />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant={"danger-outline"}
|
||||
size={"sm"}
|
||||
onClick={() => {
|
||||
if (!network) return;
|
||||
deleteRouter(network, router);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Remove
|
||||
</Button>
|
||||
<div className={"flex justify-end"}>
|
||||
<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={() => {
|
||||
if (!network) return;
|
||||
openAddRoutingPeerModal(network, router);
|
||||
}}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<SquarePenIcon size={14} className={"shrink-0"} />
|
||||
Edit
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!network) return;
|
||||
deleteRouter(network, router);
|
||||
}}
|
||||
variant={"danger"}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<Trash2 size={14} className={"shrink-0"} />
|
||||
Remove
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { NetworkRouter } from "@/interfaces/Network";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
|
||||
type Props = {
|
||||
router: NetworkRouter;
|
||||
};
|
||||
export const RoutingPeersEnabledCell = ({ router }: Props) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { network } = useNetworksContext();
|
||||
|
||||
const update = useApiCall<NetworkRouter>(
|
||||
`/networks/${network?.id}/routers/${router?.id}`,
|
||||
).put;
|
||||
|
||||
const toggle = async (enabled: boolean) => {
|
||||
notify({
|
||||
title: "Network Routing Peer",
|
||||
description: `Routing peer is now ${enabled ? "enabled" : "disabled"}`,
|
||||
loadingMessage: "Updating routing peer...",
|
||||
promise: update({
|
||||
...router,
|
||||
enabled,
|
||||
}).then(() => {
|
||||
mutate(`/networks/${network?.id}/routers`);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const isChecked = useMemo(() => {
|
||||
return router.enabled;
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ToggleSwitch
|
||||
checked={isChecked}
|
||||
size={"small"}
|
||||
onClick={() => toggle(!isChecked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,15 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { NetworkRouter } from "@/interfaces/Network";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
import { RoutingPeerMasqueradeTooltip } from "@/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch";
|
||||
|
||||
type Props = {
|
||||
router: NetworkRouter;
|
||||
@@ -14,6 +18,20 @@ export const RoutingPeersMasqueradeCell = ({ router }: Props) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { network } = useNetworksContext();
|
||||
|
||||
const isRoutingPeer = router.peer != "";
|
||||
|
||||
const { data: peer, isLoading } = useFetchApi<Peer>(
|
||||
"/peers/" + router.peer,
|
||||
true,
|
||||
false,
|
||||
isRoutingPeer,
|
||||
);
|
||||
|
||||
const isNonLinuxRoutingPeer = useMemo(() => {
|
||||
if (!peer) return false;
|
||||
return getOperatingSystem(peer.os) != OperatingSystem.LINUX;
|
||||
}, [peer]);
|
||||
|
||||
const update = useApiCall<NetworkRouter>(
|
||||
`/networks/${network?.id}/routers/${router?.id}`,
|
||||
).put;
|
||||
@@ -36,13 +54,19 @@ export const RoutingPeersMasqueradeCell = ({ router }: Props) => {
|
||||
return router.masquerade;
|
||||
}, [router]);
|
||||
|
||||
const isToggleDisabled =
|
||||
isLoading || (isRoutingPeer && isNonLinuxRoutingPeer);
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ToggleSwitch
|
||||
checked={isChecked}
|
||||
size={"small"}
|
||||
onClick={() => toggle(!isChecked)}
|
||||
/>
|
||||
<RoutingPeerMasqueradeTooltip show={isToggleDisabled}>
|
||||
<ToggleSwitch
|
||||
disabled={isToggleDisabled}
|
||||
checked={isChecked}
|
||||
size={"small"}
|
||||
onClick={() => toggle(!isChecked)}
|
||||
/>
|
||||
</RoutingPeerMasqueradeTooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import {
|
||||
@@ -38,14 +37,6 @@ export const NetworkTableColumns: ColumnDef<Network>[] = [
|
||||
{
|
||||
accessorKey: "description",
|
||||
},
|
||||
{
|
||||
accessorKey: "routers",
|
||||
accessorFn: (network) => network?.routers?.length,
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Routing Peers</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <NetworkRoutingPeerCell network={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "resources",
|
||||
accessorFn: (network) => network?.resources?.length,
|
||||
@@ -62,6 +53,14 @@ export const NetworkTableColumns: ColumnDef<Network>[] = [
|
||||
},
|
||||
cell: ({ row }) => <NetworkPolicyCell network={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "routers",
|
||||
accessorFn: (network) => network?.routers?.length,
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Routing Peers</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <NetworkRoutingPeerCell network={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
@@ -94,20 +93,6 @@ export default function NetworksTable({
|
||||
],
|
||||
);
|
||||
|
||||
const { confirm } = useDialog();
|
||||
|
||||
const showConfirm = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Do you want to add a resource to 'Office Network' now?`,
|
||||
description:
|
||||
"Peers will be able to access your network resources once you add them.",
|
||||
confirmText: "Add Resource",
|
||||
cancelText: "Later",
|
||||
type: "default",
|
||||
});
|
||||
if (!choice) return;
|
||||
};
|
||||
|
||||
return (
|
||||
<NetworkProvider>
|
||||
<DataTable
|
||||
@@ -135,7 +120,7 @@ export default function NetworksTable({
|
||||
}
|
||||
title={"Create New Network"}
|
||||
description={
|
||||
"It looks like you don't have any networks. Access resources like LANs and VPC by adding a network."
|
||||
"It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network."
|
||||
}
|
||||
button={
|
||||
<div className={"gap-x-4 flex items-center justify-center"}>
|
||||
@@ -146,9 +131,7 @@ export default function NetworksTable({
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/networks"
|
||||
}
|
||||
href={"https://docs.netbird.io/how-to/networks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Networks
|
||||
|
||||
73
src/modules/peer/PeerExpirationToggle.tsx
Normal file
73
src/modules/peer/PeerExpirationToggle.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import FancyToggleSwitch, {
|
||||
FancyToggleSwitchVariants,
|
||||
} from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
} & FancyToggleSwitchVariants;
|
||||
|
||||
export const PeerExpirationToggle = ({
|
||||
peer,
|
||||
value,
|
||||
onChange,
|
||||
title = "Session Expiration",
|
||||
description = "Enable to require SSO login peers to re-authenticate when their session expires after a certain period of time.",
|
||||
icon,
|
||||
className,
|
||||
variant = "default",
|
||||
}: Props) => {
|
||||
const { isUser } = useLoggedInUser();
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}>
|
||||
{!peer.user_id ? (
|
||||
<>
|
||||
<IconInfoCircle size={14} />
|
||||
<span>
|
||||
This setting is disabled for all peers added with an setup-key.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LockIcon size={14} />
|
||||
<span>
|
||||
{`You don't have the required permissions to update this setting.`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className={"w-full block"}
|
||||
disabled={!!peer.user_id && !isUser}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
className={className}
|
||||
disabled={!peer.user_id || isUser}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
variant={variant}
|
||||
label={
|
||||
<>
|
||||
{icon}
|
||||
{title}
|
||||
</>
|
||||
}
|
||||
helpText={description}
|
||||
/>
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { notify } from "@components/Notification";
|
||||
import { IconCloudLock, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import {
|
||||
MonitorIcon,
|
||||
MoreVertical,
|
||||
TerminalSquare,
|
||||
TimerResetIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -28,18 +29,20 @@ export default function PeerActionCell() {
|
||||
|
||||
const toggleLoginExpiration = async () => {
|
||||
const text = peer.login_expiration_enabled ? "disabled" : "enabled";
|
||||
const disableLoginExpiration = peer.login_expiration_enabled;
|
||||
notify({
|
||||
title: `Login expiration is ${text}`,
|
||||
description: `The Login expiration for the peer ${peer.name} was successfully ${text}.`,
|
||||
promise: update(
|
||||
peer.name,
|
||||
peer.ssh_enabled,
|
||||
!peer.login_expiration_enabled,
|
||||
).then(() => {
|
||||
title: `Session expiration is ${text}`,
|
||||
description: `Session expiration for peer ${peer.name} was successfully ${text}.`,
|
||||
promise: update({
|
||||
loginExpiration: !peer.login_expiration_enabled,
|
||||
inactivityExpiration: disableLoginExpiration
|
||||
? false
|
||||
: peer.inactivity_expiration_enabled,
|
||||
}).then(() => {
|
||||
mutate("/peers");
|
||||
mutate("/groups");
|
||||
}),
|
||||
loadingMessage: "Updating login expiration...",
|
||||
loadingMessage: "Updating session expiration...",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -48,11 +51,9 @@ export default function PeerActionCell() {
|
||||
notify({
|
||||
title: `SSH Server is ${text}`,
|
||||
description: `The SSH Server for the peer ${peer.name} was successfully ${text}.`,
|
||||
promise: update(
|
||||
peer.name,
|
||||
!peer.ssh_enabled,
|
||||
peer.login_expiration_enabled,
|
||||
).then(() => {
|
||||
promise: update({
|
||||
ssh: !peer.ssh_enabled,
|
||||
}).then(() => {
|
||||
mutate("/peers");
|
||||
mutate("/groups");
|
||||
}),
|
||||
@@ -90,8 +91,7 @@ export default function PeerActionCell() {
|
||||
>
|
||||
<IconInfoCircle size={14} />
|
||||
<span>
|
||||
Login expiration is disabled for all peers added with an
|
||||
setup-key.
|
||||
Expiration is disabled for all peers added with an setup-key.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -103,8 +103,8 @@ export default function PeerActionCell() {
|
||||
disabled={!peer.user_id}
|
||||
>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
<IconCloudLock size={14} className={"shrink-0"} />
|
||||
{peer.login_expiration_enabled ? "Disable" : "Enable"} Login
|
||||
<TimerResetIcon size={14} className={"shrink-0"} />
|
||||
{peer.login_expiration_enabled ? "Disable" : "Enable"} Session
|
||||
Expiration
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -15,7 +15,9 @@ export default function PeerAddressCell({ peer }: Props) {
|
||||
return (
|
||||
<FullTooltip
|
||||
side={"top"}
|
||||
interactive={false}
|
||||
interactive={true}
|
||||
delayDuration={250}
|
||||
skipDelayDuration={100}
|
||||
contentClassName={"p-0"}
|
||||
content={<PeerAddressTooltipContent peer={peer} />}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FlagIcon, GlobeIcon, MapPin, NetworkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -26,23 +28,77 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
|
||||
<ListItem
|
||||
icon={<MapPin size={14} />}
|
||||
label={"NetBird IP"}
|
||||
value={peer.ip}
|
||||
value={
|
||||
<CopyToClipboardText
|
||||
iconAlignment={"right"}
|
||||
message={"NetBird IP has been copied to your clipboard"}
|
||||
alwaysShowIcon={true}
|
||||
>
|
||||
{peer.ip}
|
||||
</CopyToClipboardText>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
icon={<NetworkIcon size={14} />}
|
||||
label={"Public IP"}
|
||||
value={peer.connection_ip}
|
||||
value={
|
||||
<CopyToClipboardText
|
||||
iconAlignment={"right"}
|
||||
message={"Public IP has been copied to your clipboard"}
|
||||
alwaysShowIcon={true}
|
||||
>
|
||||
{peer.connection_ip}
|
||||
</CopyToClipboardText>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
icon={<GlobeIcon size={14} />}
|
||||
label={"Domain"}
|
||||
value={peer.dns_label}
|
||||
className={
|
||||
peer?.extra_dns_labels && peer.extra_dns_labels.length > 0
|
||||
? "items-start"
|
||||
: ""
|
||||
}
|
||||
value={
|
||||
<div className={"text-right flex flex-col gap-[6px]"}>
|
||||
<CopyToClipboardText
|
||||
iconAlignment={"right"}
|
||||
message={"DNS label has been copied to your clipboard"}
|
||||
className={"text-right justify-end"}
|
||||
alwaysShowIcon={true}
|
||||
>
|
||||
{peer.dns_label}
|
||||
</CopyToClipboardText>
|
||||
|
||||
{peer?.extra_dns_labels?.map((label) => (
|
||||
<CopyToClipboardText
|
||||
key={label}
|
||||
className={"text-right justify-end"}
|
||||
iconAlignment={"right"}
|
||||
message={"DNS label has been copied to your clipboard"}
|
||||
alwaysShowIcon={true}
|
||||
>
|
||||
{label}
|
||||
</CopyToClipboardText>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
icon={<FlagIcon size={14} />}
|
||||
label={"Region"}
|
||||
value={
|
||||
isLoading && !countryText ? <Skeleton width={100} /> : countryText
|
||||
isLoading && !countryText ? (
|
||||
<Skeleton width={100} />
|
||||
) : (
|
||||
<CopyToClipboardText
|
||||
iconAlignment={"right"}
|
||||
message={"Region has been copied to your clipboard"}
|
||||
alwaysShowIcon={true}
|
||||
>
|
||||
{countryText}
|
||||
</CopyToClipboardText>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -53,22 +109,25 @@ const ListItem = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex justify-between gap-10 border-b border-nb-gray-920 py-2 px-4 last:border-b-0"
|
||||
}
|
||||
className={cn(
|
||||
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={"text-nb-gray-400"}>{value}</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/Tooltip";
|
||||
import { Barcode, CpuIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import React, { useMemo } from "react";
|
||||
import { FaWindows } from "react-icons/fa6";
|
||||
@@ -14,7 +15,11 @@ import FreeBSDLogo from "@/assets/os-icons/FreeBSD.png";
|
||||
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
|
||||
export function PeerOSCell({ os }: { os: string }) {
|
||||
type Props = {
|
||||
os: string;
|
||||
serial?: string;
|
||||
};
|
||||
export function PeerOSCell({ os, serial }: Readonly<Props>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={1}>
|
||||
@@ -33,14 +38,47 @@ export function PeerOSCell({ os }: { os: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className={"text-neutral-300 flex flex-col gap-1"}>{os}</div>
|
||||
<TooltipContent className={"!p-0"}>
|
||||
<div>
|
||||
<ListItem icon={<CpuIcon size={14} />} label={"OS"} value={os} />
|
||||
{serial && serial !== "" && (
|
||||
<ListItem
|
||||
icon={<Barcode size={14} />}
|
||||
label={"Serial Number"}
|
||||
value={serial}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const ListItem = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex justify-between gap-5 border-b border-nb-gray-920 py-2 px-4 last:border-b-0 text-xs"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={"text-nb-gray-400"}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function OSLogo({ os }: { os: string }) {
|
||||
const icon = useMemo(() => {
|
||||
return getOperatingSystem(os);
|
||||
|
||||
@@ -3,8 +3,7 @@ import Button from "@components/Button";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { notify } from "@components/Notification";
|
||||
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
|
||||
import { IconCloudLock } from "@tabler/icons-react";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { HelpCircle, TimerResetIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
@@ -36,12 +35,12 @@ export default function PeerStatusCell({ peer }: Props) {
|
||||
notify({
|
||||
title: `Peer ${peer.name} approved`,
|
||||
description: `This peer was approved and can now connect to other peers.`,
|
||||
promise: update(
|
||||
peer.name,
|
||||
peer.ssh_enabled,
|
||||
peer.login_expiration_enabled,
|
||||
false,
|
||||
).then(() => {
|
||||
promise: update({
|
||||
name: peer.name,
|
||||
ssh: peer.ssh_enabled,
|
||||
loginExpiration: peer.login_expiration_enabled,
|
||||
approval_required: false,
|
||||
}).then(() => {
|
||||
mutate("/peers");
|
||||
mutate("/groups");
|
||||
}),
|
||||
@@ -83,8 +82,8 @@ export default function PeerStatusCell({ peer }: Props) {
|
||||
) : (
|
||||
<div className={"flex gap-3 items-center text-xs"}>
|
||||
{!peer.login_expiration_enabled && (
|
||||
<Badge variant={"gray"} className={"px-3"}>
|
||||
<IconCloudLock size={15} className={"mr-1"} />
|
||||
<Badge variant={"gray"} className={"px-2"}>
|
||||
<TimerResetIcon size={13} className={"relative -top-[1px]"} />
|
||||
Expiration disabled
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -63,7 +63,8 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
accessorFn: (peer) => `${peer?.name}${peer?.dns_label}`,
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Name</DataTableHeader>;
|
||||
},
|
||||
@@ -94,6 +95,7 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
accessorFn: (peer) => (peer.user ? peer.user?.email : "Unknown"),
|
||||
},
|
||||
{
|
||||
id: "dns_label",
|
||||
accessorKey: "dns_label",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Address</DataTableHeader>;
|
||||
@@ -132,11 +134,20 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "os",
|
||||
accessorKey: "os",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>OS</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <PeerOSCell os={row.original.os} />,
|
||||
cell: ({ row }) => <PeerOSCell os={row.original.os} serial={row.original.serial_number} />,
|
||||
},
|
||||
{
|
||||
id: "serial",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Serial number</DataTableHeader>;
|
||||
},
|
||||
accessorFn: (peer) => peer.serial_number,
|
||||
sortingFn: "text",
|
||||
},
|
||||
{
|
||||
accessorKey: "version",
|
||||
@@ -193,6 +204,10 @@ export default function PeersTable({ peers, isLoading, headingTarget }: Props) {
|
||||
id: "connected",
|
||||
desc: true,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
{
|
||||
id: "last_seen",
|
||||
desc: true,
|
||||
@@ -235,7 +250,7 @@ export default function PeersTable({ peers, isLoading, headingTarget }: Props) {
|
||||
setSorting={setSorting}
|
||||
columns={PeersTableColumns}
|
||||
data={peers}
|
||||
searchPlaceholder={"Search by name, IP, owner or group..."}
|
||||
searchPlaceholder={"Search by name, IP, Serial, owner or group..."}
|
||||
columnVisibility={{
|
||||
select: !isUser,
|
||||
connected: false,
|
||||
@@ -243,6 +258,7 @@ export default function PeersTable({ peers, isLoading, headingTarget }: Props) {
|
||||
group_name_strings: false,
|
||||
group_names: false,
|
||||
ip: false,
|
||||
serial: false,
|
||||
user_name: false,
|
||||
user_email: false,
|
||||
actions: !isUser,
|
||||
|
||||
@@ -3,11 +3,15 @@ import { ArrowUpDown, InfoIcon } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
metric?: number;
|
||||
useHoverStyle?: boolean;
|
||||
};
|
||||
export default function RouteMetricCell({ metric }: Props) {
|
||||
export default function RouteMetricCell({
|
||||
metric,
|
||||
useHoverStyle = true,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<FullTooltip
|
||||
hoverButton={true}
|
||||
hoverButton={useHoverStyle}
|
||||
isAction={true}
|
||||
content={
|
||||
<div className={"text-xs max-w-xs flex gap-2 items-center"}>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SegmentedTabs } from "@components/SegmentedTabs";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { Textarea } from "@components/Textarea";
|
||||
import InputDomain, { domainReducer } from "@components/ui/InputDomain";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { IconDirectionSign } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import cidr from "ip-cidr";
|
||||
@@ -42,18 +43,19 @@ import {
|
||||
RouteIcon,
|
||||
Settings2,
|
||||
Text,
|
||||
VenetianMask,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { useRoutes } from "@/contexts/RoutesProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { RoutingPeerMasqueradeSwitch } from "@/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -100,7 +102,6 @@ export default function RouteModal({ children, open, setOpen }: Props) {
|
||||
},
|
||||
],
|
||||
};
|
||||
console.log(newPolicy);
|
||||
setNewPolicy(newPolicy);
|
||||
setRoutePolicyModal(true);
|
||||
};
|
||||
@@ -227,6 +228,15 @@ export function RouteModalContent({
|
||||
const [metric, setMetric] = useState("9999");
|
||||
const [masquerade, setMasquerade] = useState<boolean>(true);
|
||||
|
||||
const isNonLinuxRoutingPeer = useMemo(() => {
|
||||
if (!routingPeer) return false;
|
||||
return getOperatingSystem(routingPeer.os) != OperatingSystem.LINUX;
|
||||
}, [routingPeer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNonLinuxRoutingPeer) setMasquerade(true);
|
||||
}, [isNonLinuxRoutingPeer]);
|
||||
|
||||
/**
|
||||
* Create Route
|
||||
*/
|
||||
@@ -291,7 +301,7 @@ export function RouteModalContent({
|
||||
domains: domainRouteNames,
|
||||
keep_route: useKeepRoute,
|
||||
metric: Number(metric) || 9999,
|
||||
masquerade: masquerade,
|
||||
masquerade: useSinglePeer && isNonLinuxRoutingPeer ? true : masquerade,
|
||||
groups: groupIds,
|
||||
access_control_groups: accessControlGroupIds || undefined,
|
||||
},
|
||||
@@ -583,7 +593,14 @@ export function RouteModalContent({
|
||||
{exitNode && peer ? (
|
||||
<></>
|
||||
) : (
|
||||
<SegmentedTabs value={peerTab} onChange={setPeerTab}>
|
||||
<SegmentedTabs
|
||||
value={peerTab}
|
||||
onChange={(state) => {
|
||||
setPeerTab(state);
|
||||
setRoutingPeer(undefined);
|
||||
setRoutingPeerGroups([]);
|
||||
}}
|
||||
>
|
||||
<SegmentedTabs.List>
|
||||
<SegmentedTabs.Trigger value={"routing-peer"}>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
@@ -611,7 +628,7 @@ export function RouteModalContent({
|
||||
<SegmentedTabs.Content value={"peer-group"}>
|
||||
<div>
|
||||
<HelpText>
|
||||
Assign a peer group with Linux machines to be used as
|
||||
Assign a peer group with machines to be used as
|
||||
{exitNode ? " exit nodes." : " routing peers."}
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
@@ -700,19 +717,12 @@ export function RouteModalContent({
|
||||
}
|
||||
helpText={"Use this switch to enable or disable the route."}
|
||||
/>
|
||||
|
||||
{!exitNode && (
|
||||
<FancyToggleSwitch
|
||||
<RoutingPeerMasqueradeSwitch
|
||||
value={masquerade}
|
||||
onChange={setMasquerade}
|
||||
label={
|
||||
<>
|
||||
<VenetianMask size={15} />
|
||||
Masquerade
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Allow access to your private networks without configuring routes on your local routers or other devices."
|
||||
}
|
||||
disabled={isNonLinuxRoutingPeer}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PeerSelector } from "@components/PeerSelector";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { Textarea } from "@components/Textarea";
|
||||
import { DomainsTooltip } from "@components/ui/DomainListBadge";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { uniqBy } from "lodash";
|
||||
import {
|
||||
@@ -28,18 +29,19 @@ import {
|
||||
RouteIcon,
|
||||
Settings2,
|
||||
Text,
|
||||
VenetianMask,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { useGroupRoute } from "@/contexts/GroupRouteProvider";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePeers } from "@/contexts/PeersProvider";
|
||||
import { useRoutes } from "@/contexts/RoutesProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { RoutingPeerMasqueradeSwitch } from "@/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch";
|
||||
|
||||
type Props = {
|
||||
route: Route;
|
||||
@@ -110,6 +112,15 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const isNonLinuxRoutingPeer = useMemo(() => {
|
||||
if (!routingPeer) return false;
|
||||
return getOperatingSystem(routingPeer.os) != OperatingSystem.LINUX;
|
||||
}, [routingPeer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNonLinuxRoutingPeer) setMasquerade(true);
|
||||
}, [isNonLinuxRoutingPeer]);
|
||||
|
||||
const isMasqueradeDisabled = useMemo(() => {
|
||||
if (isExitNode) return true;
|
||||
return routeType === "domains";
|
||||
@@ -243,7 +254,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
peer: useSinglePeer ? routingPeer?.id : undefined,
|
||||
peer_groups: useSinglePeer ? undefined : peerGroups || undefined,
|
||||
metric: Number(metric) || 9999,
|
||||
masquerade: masquerade,
|
||||
masquerade: useSinglePeer && isNonLinuxRoutingPeer ? true : masquerade,
|
||||
groups: groupIds,
|
||||
access_control_groups: accessControlGroupIds || undefined,
|
||||
},
|
||||
@@ -380,7 +391,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
<div>
|
||||
<Label>Peer Group</Label>
|
||||
<HelpText>
|
||||
Assign a peer group with Linux machines to be used as
|
||||
Assign a peer group with machines to be used as
|
||||
{isExitNode ? " exit nodes." : " routing peers."}
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
@@ -446,18 +457,10 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
helpText={"Use this switch to enable or disable the route."}
|
||||
/>
|
||||
{!isExitNode && (
|
||||
<FancyToggleSwitch
|
||||
<RoutingPeerMasqueradeSwitch
|
||||
value={masquerade}
|
||||
onChange={setMasquerade}
|
||||
label={
|
||||
<>
|
||||
<VenetianMask size={15} />
|
||||
Masquerade
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Allow access to your private networks without configuring routes on your local routers or other devices."
|
||||
}
|
||||
disabled={isNonLinuxRoutingPeer}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("flex justify-between")}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { notify } from "@components/Notification";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -13,11 +14,18 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import { useExpirationState } from "@hooks/useExpirationState";
|
||||
import { convertToSeconds } from "@hooks/useTimeFormatter";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cn, isInt } from "@utils/helpers";
|
||||
import { CalendarClock, ShieldIcon, TimerReset } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import {
|
||||
CalendarClock,
|
||||
ExternalLinkIcon,
|
||||
ShieldIcon,
|
||||
TimerResetIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import SettingsIcon from "@/assets/icons/SettingsIcon";
|
||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||
@@ -27,7 +35,7 @@ type Props = {
|
||||
account: Account;
|
||||
};
|
||||
|
||||
export default function AuthenticationTab({ account }: Props) {
|
||||
export default function AuthenticationTab({ account }: Readonly<Props>) {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
/**
|
||||
@@ -41,38 +49,32 @@ export default function AuthenticationTab({ account }: Props) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Login expiration enabled
|
||||
*/
|
||||
const [loginExpiration, setLoginExpiration] = useState(
|
||||
account.settings.peer_login_expiration_enabled,
|
||||
);
|
||||
|
||||
/**
|
||||
* Expiration in seconds
|
||||
*/
|
||||
const [expiresInSeconds] = useState(
|
||||
account.settings.peer_login_expiration || 86400,
|
||||
);
|
||||
|
||||
const [expiresIn, setExpiresIn] = useState(() => {
|
||||
if (expiresInSeconds <= 172800) {
|
||||
const hours = expiresInSeconds / 3600;
|
||||
return isInt(hours) ? hours.toString() : hours.toFixed(2).toString();
|
||||
}
|
||||
const days = expiresInSeconds / 86400;
|
||||
return isInt(days) ? days.toString() : days.toFixed(2).toString();
|
||||
// Peer Expiration
|
||||
const [
|
||||
loginExpiration,
|
||||
setLoginExpiration,
|
||||
expiresIn,
|
||||
setExpiresIn,
|
||||
expireInterval,
|
||||
setExpireInterval,
|
||||
] = useExpirationState({
|
||||
enabled: account.settings.peer_login_expiration_enabled,
|
||||
expirationInSeconds: account.settings.peer_login_expiration || 86400,
|
||||
});
|
||||
|
||||
/**
|
||||
* Interval
|
||||
*/
|
||||
const initialInterval = useMemo(() => {
|
||||
if (expiresInSeconds <= 172800) return "hours";
|
||||
return "days";
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const [expireInterval, setExpireInterval] = useState(initialInterval);
|
||||
// Peer Inactivity Expiration
|
||||
const [
|
||||
peerInactivityExpirationEnabled,
|
||||
setPeerInactivityExpirationEnabled,
|
||||
peerInactivityExpiresIn,
|
||||
setPeerInactivityExpiresIn,
|
||||
peerInactivityExpireInterval,
|
||||
setPeerInactivityExpireInterval,
|
||||
] = useExpirationState({
|
||||
enabled: account.settings.peer_inactivity_expiration_enabled,
|
||||
expirationInSeconds: account.settings.peer_inactivity_expiration || 600,
|
||||
timeRange: ["minutes", "hours", "days"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Save changes
|
||||
@@ -84,13 +86,17 @@ export default function AuthenticationTab({ account }: Props) {
|
||||
loginExpiration,
|
||||
expiresIn,
|
||||
expireInterval,
|
||||
peerInactivityExpirationEnabled,
|
||||
peerInactivityExpiresIn,
|
||||
peerInactivityExpireInterval,
|
||||
]);
|
||||
|
||||
const saveChanges = async () => {
|
||||
const expiration =
|
||||
expireInterval === "days"
|
||||
? Number(expiresIn) * 86400
|
||||
: Number(expiresIn) * 3600;
|
||||
const expiration = convertToSeconds(expiresIn, expireInterval);
|
||||
const peerInactivityExpiration = convertToSeconds(
|
||||
peerInactivityExpiresIn,
|
||||
peerInactivityExpireInterval,
|
||||
);
|
||||
|
||||
notify({
|
||||
title: "Save Authentication Settings",
|
||||
@@ -102,14 +108,26 @@ export default function AuthenticationTab({ account }: Props) {
|
||||
...account.settings,
|
||||
peer_login_expiration_enabled: loginExpiration,
|
||||
peer_login_expiration: loginExpiration ? expiration : 86400,
|
||||
peer_inactivity_expiration_enabled: loginExpiration
|
||||
? peerInactivityExpirationEnabled
|
||||
: false,
|
||||
peer_inactivity_expiration: 600,
|
||||
extra: {
|
||||
peer_approval_enabled: peerApproval,
|
||||
},
|
||||
},
|
||||
})
|
||||
} as Account)
|
||||
.then(() => {
|
||||
mutate("/accounts");
|
||||
updateRef([peerApproval, loginExpiration, expiresIn, expireInterval]);
|
||||
updateRef([
|
||||
peerApproval,
|
||||
loginExpiration,
|
||||
expiresIn,
|
||||
expireInterval,
|
||||
peerInactivityExpirationEnabled,
|
||||
peerInactivityExpiresIn,
|
||||
peerInactivityExpireInterval,
|
||||
]);
|
||||
}),
|
||||
loadingMessage: "Saving the authentication settings...",
|
||||
});
|
||||
@@ -132,25 +150,45 @@ export default function AuthenticationTab({ account }: Props) {
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<div className={"flex items-start justify-between"}>
|
||||
<h1>Authentication</h1>
|
||||
<div>
|
||||
<h1>Authentication</h1>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/enforce-periodic-user-authentication"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Authentication
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={!hasChanges}
|
||||
onClick={saveChanges}
|
||||
data-cy={"save-authentication-settings"}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col gap-6 w-full mt-8"}>
|
||||
<div>
|
||||
<div className={"flex flex-col gap-6 w-full mt-8 mb-3"}>
|
||||
<div className={"flex flex-col"}>
|
||||
<FancyToggleSwitch
|
||||
value={loginExpiration}
|
||||
onChange={setLoginExpiration}
|
||||
onChange={(state) => {
|
||||
setLoginExpiration(state);
|
||||
!state && setPeerInactivityExpirationEnabled(false);
|
||||
}}
|
||||
dataCy={"peer-login-expiration"}
|
||||
label={
|
||||
<>
|
||||
<TimerReset size={15} />
|
||||
Peer login expiration
|
||||
<TimerResetIcon size={15} />
|
||||
Peer Session Expiration
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
@@ -160,68 +198,79 @@ export default function AuthenticationTab({ account }: Props) {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-6",
|
||||
!loginExpiration && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className={"w-auto"}>
|
||||
<Label>Expires in</Label>
|
||||
<HelpText>
|
||||
Time after which every peer added with SSO login will require
|
||||
re-authentication
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-full flex gap-3"}>
|
||||
<Input
|
||||
placeholder={"7"}
|
||||
maxWidthClass={"min-w-[100px]"}
|
||||
min={1}
|
||||
disabled={!loginExpiration}
|
||||
max={180}
|
||||
className={"w-full"}
|
||||
value={expiresIn}
|
||||
type={"number"}
|
||||
onChange={(e) => setExpiresIn(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
disabled={!loginExpiration}
|
||||
value={expireInterval}
|
||||
onValueChange={(v) => setExpireInterval(v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<CalendarClock size={15} className={"text-nb-gray-300"} />
|
||||
<SelectValue placeholder="Select interval..." />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="days">Days</SelectItem>
|
||||
<SelectItem value="hours">Hours</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Multi-factor authentication (MFA)</Label>
|
||||
<HelpText className={"inline"}>
|
||||
<>
|
||||
If your IdP supports MFA, it will work automatically with
|
||||
NetBird.
|
||||
<br /> Otherwise, contact us at{" "}
|
||||
<InlineLink
|
||||
href={"mailto:support@netbird.io"}
|
||||
className={"inline"}
|
||||
>
|
||||
{" "}
|
||||
support@netbird.io
|
||||
</InlineLink>{" "}
|
||||
to enable this feature.
|
||||
</>
|
||||
</HelpText>
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex justify-between gap-10 mt-2")}>
|
||||
<div className={"w-full"}>
|
||||
<Label>Session Expiration</Label>
|
||||
<HelpText>
|
||||
Time after which every peer added with SSO login will
|
||||
require re-authentication.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-full flex gap-3"}>
|
||||
<Input
|
||||
placeholder={"7"}
|
||||
maxWidthClass={"min-w-[100px]"}
|
||||
min={1}
|
||||
disabled={!loginExpiration}
|
||||
data-cy={"peer-login-expiration-input"}
|
||||
max={180}
|
||||
className={"w-full"}
|
||||
value={expiresIn}
|
||||
type={"number"}
|
||||
onChange={(e) => setExpiresIn(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
disabled={!loginExpiration}
|
||||
value={expireInterval}
|
||||
onValueChange={(v) => setExpireInterval(v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
data-cy={"peer-login-expiration-select"}
|
||||
>
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<CalendarClock
|
||||
size={15}
|
||||
className={"text-nb-gray-300"}
|
||||
/>
|
||||
<SelectValue
|
||||
placeholder="Select interval..."
|
||||
data-cy={"peer-login-expiration-select-value"}
|
||||
/>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
data-cy={"peer-login-expiration-select-content"}
|
||||
>
|
||||
<SelectItem value="days">Days</SelectItem>
|
||||
<SelectItem value="hours">Hours</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<FancyToggleSwitch
|
||||
variant={"blank"}
|
||||
value={peerInactivityExpirationEnabled}
|
||||
onChange={setPeerInactivityExpirationEnabled}
|
||||
dataCy={"peer-inactivity-expiration"}
|
||||
label={<>Require login after disconnect</>}
|
||||
helpText={
|
||||
<>
|
||||
Enable to require authentication after users disconnect from
|
||||
management for 10 minutes.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,14 @@ import React from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import { useGroupIdentification } from "@/modules/groups/useGroupIdentification";
|
||||
import { GroupUsage } from "@/modules/settings/useGroupsUsage";
|
||||
|
||||
type Props = {
|
||||
group: GroupUsage;
|
||||
in_use: boolean;
|
||||
};
|
||||
export default function GroupsActionCell({ group, in_use }: Props) {
|
||||
export default function GroupsActionCell({ group, in_use }: Readonly<Props>) {
|
||||
const { confirm } = useDialog();
|
||||
const deleteRequest = useApiCall<SetupKey>("/groups/" + group.id);
|
||||
const { mutate } = useSWRConfig();
|
||||
@@ -42,18 +43,35 @@ export default function GroupsActionCell({ group, in_use }: Props) {
|
||||
handleRevoke().then();
|
||||
};
|
||||
|
||||
const { isRegularGroup, isJWTGroup } = useGroupIdentification({
|
||||
id: group?.id,
|
||||
issued: group?.issued,
|
||||
});
|
||||
|
||||
const isDisabled = in_use || !isRegularGroup;
|
||||
|
||||
const getDisabledText = () => {
|
||||
if (isRegularGroup) {
|
||||
return "Remove dependencies to this group to delete it.";
|
||||
} else if (isJWTGroup) {
|
||||
return "This group is issued by JWT and cannot be deleted.";
|
||||
} else {
|
||||
return "This group is issued by an IdP and cannot be deleted";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<FullTooltip
|
||||
content={"Remove dependencies to this group to delete it."}
|
||||
content={<div className={"text-xs max-w-xs"}>{getDisabledText()}</div>}
|
||||
interactive={false}
|
||||
disabled={!in_use}
|
||||
disabled={!isDisabled}
|
||||
>
|
||||
<Button
|
||||
variant={"danger-outline"}
|
||||
size={"sm"}
|
||||
onClick={handleConfirm}
|
||||
disabled={in_use}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
|
||||
38
src/modules/settings/GroupsNameCell.tsx
Normal file
38
src/modules/settings/GroupsNameCell.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React from "react";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
|
||||
type Props = {
|
||||
active: boolean;
|
||||
group: Group;
|
||||
};
|
||||
export default function GroupsNameCell({ active, group }: Readonly<Props>) {
|
||||
return (
|
||||
<div className={cn("gap-3 dark:text-neutral-300 text-neutral-500 min-w-0")}>
|
||||
<div className={"flex flex-col gap-1"}>
|
||||
<div className={"flex gap-2.5 items-center"}>
|
||||
<div className={"flex items-center justify-center h-full"}>
|
||||
<GroupBadgeIcon id={group?.id} issued={group?.issued} />
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col min-w-0"}>
|
||||
<div
|
||||
className={"font-medium flex gap-2 items-center justify-center"}
|
||||
>
|
||||
<TextWithTooltip text={group?.name} maxChars={25} />
|
||||
</div>
|
||||
</div>
|
||||
<CircleIcon
|
||||
size={8}
|
||||
active={active}
|
||||
inactiveDot={"gray"}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { FolderGit2Icon } from "lucide-react";
|
||||
import { FolderGit2Icon, Layers3Icon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
@@ -14,9 +14,9 @@ import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow";
|
||||
import GroupsActionCell from "@/modules/settings/GroupsActionCell";
|
||||
import GroupsCountCell from "@/modules/settings/GroupsCountCell";
|
||||
import GroupsNameCell from "@/modules/settings/GroupsNameCell";
|
||||
import useGroupsUsage, { GroupUsage } from "@/modules/settings/useGroupsUsage";
|
||||
|
||||
// Peers, Access Controls, DNS, Routes, Setup Keys, Users
|
||||
@@ -28,7 +28,16 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const in_use = !!row.getValue("in_use");
|
||||
return <ActiveInactiveRow active={in_use} text={row.original.name} />;
|
||||
return (
|
||||
<GroupsNameCell
|
||||
active={in_use}
|
||||
group={{
|
||||
id: row.original?.id,
|
||||
issued: row.original?.issued,
|
||||
name: row.original?.name,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
sortingFn: "text",
|
||||
},
|
||||
@@ -138,6 +147,29 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "resources_count",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
tooltip={
|
||||
<div className={"text-sm normal-case"}>Network Resources</div>
|
||||
}
|
||||
>
|
||||
<Layers3Icon size={12} />
|
||||
</DataTableHeader>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<GroupsCountCell
|
||||
icon={<Layers3Icon size={10} />}
|
||||
groupName={row.original.name}
|
||||
text={"Network Resource(s)"}
|
||||
count={row.original.resources_count}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "users_count",
|
||||
header: ({ column }) => {
|
||||
@@ -172,7 +204,8 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
row.policies_count > 0 ||
|
||||
row.routes_count > 0 ||
|
||||
row.setup_keys_count > 0 ||
|
||||
row.users_count > 0
|
||||
row.users_count > 0 ||
|
||||
row.resources_count > 0
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -189,7 +222,7 @@ type Props = {
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
};
|
||||
|
||||
export default function GroupsTable({ headingTarget }: Props) {
|
||||
export default function GroupsTable({ headingTarget }: Readonly<Props>) {
|
||||
const groups = useGroupsUsage();
|
||||
const path = usePathname();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import useFetchApi from "@utils/api";
|
||||
import { useMemo } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Group, GroupIssued } from "@/interfaces/Group";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
@@ -10,12 +10,14 @@ import { User } from "@/interfaces/User";
|
||||
export interface GroupUsage {
|
||||
id: string;
|
||||
name: string;
|
||||
issued: GroupIssued;
|
||||
peers_count: number;
|
||||
policies_count: number;
|
||||
nameservers_count: number;
|
||||
routes_count: number;
|
||||
setup_keys_count: number;
|
||||
users_count: number;
|
||||
resources_count: number;
|
||||
}
|
||||
|
||||
export default function useGroupsUsage() {
|
||||
@@ -124,8 +126,10 @@ export default function useGroupsUsage() {
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
issued: group.issued,
|
||||
name: group.name,
|
||||
peers_count: group.peers_count,
|
||||
resources_count: group.resources_count,
|
||||
policies_count: policyCount,
|
||||
nameservers_count: nameserverCount,
|
||||
routes_count: routeCount,
|
||||
|
||||
@@ -38,6 +38,7 @@ export default function SetupKeyActionCell({ setupKey }: Readonly<Props>) {
|
||||
auto_groups: setupKey.auto_groups,
|
||||
usage_limit: setupKey.usage_limit,
|
||||
ephemeral: setupKey.ephemeral,
|
||||
allow_extra_dns_labels: setupKey.allow_extra_dns_labels,
|
||||
})
|
||||
.then(() => {
|
||||
mutate("/setup-keys");
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function SetupKeyGroupsCell({ setupKey }: Readonly<Props>) {
|
||||
auto_groups: groups?.map((group) => group.id) || [],
|
||||
usage_limit: setupKey.usage_limit,
|
||||
ephemeral: setupKey.ephemeral,
|
||||
allow_extra_dns_labels: setupKey.allow_extra_dns_labels,
|
||||
})
|
||||
.then(() => {
|
||||
setModal(false);
|
||||
|
||||
@@ -23,8 +23,9 @@ import { cn } from "@utils/helpers";
|
||||
import { trim } from "lodash";
|
||||
import {
|
||||
AlarmClock,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
GlobeIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
PlusCircle,
|
||||
PowerOffIcon,
|
||||
@@ -32,25 +33,30 @@ import {
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import useCopyToClipboard from "@/hooks/useCopyToClipboard";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
name?: string;
|
||||
showOnlyRoutingPeerOS?: boolean;
|
||||
};
|
||||
|
||||
const copyMessage = "Setup-Key was copied to your clipboard!";
|
||||
|
||||
export default function SetupKeyModal({
|
||||
children,
|
||||
open,
|
||||
setOpen,
|
||||
name,
|
||||
showOnlyRoutingPeerOS,
|
||||
}: Readonly<Props>) {
|
||||
const [successModal, setSuccessModal] = useState(false);
|
||||
const [setupKey, setSetupKey] = useState<SetupKey>();
|
||||
const [, copy] = useCopyToClipboard(setupKey?.key);
|
||||
|
||||
const [installModal, setInstallModal] = useState(false);
|
||||
const handleSuccess = (setupKey: SetupKey) => {
|
||||
setSetupKey(setupKey);
|
||||
setSuccessModal(true);
|
||||
@@ -60,8 +66,24 @@ export default function SetupKeyModal({
|
||||
<>
|
||||
<Modal open={open} onOpenChange={setOpen} key={open ? 1 : 0}>
|
||||
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
|
||||
<SetupKeyModalContent onSuccess={handleSuccess} />
|
||||
<SetupKeyModalContent onSuccess={handleSuccess} predefinedName={name} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={installModal}
|
||||
onOpenChange={(state) => {
|
||||
setInstallModal(state);
|
||||
setOpen(false);
|
||||
}}
|
||||
key={installModal ? 2 : 3}
|
||||
>
|
||||
<SetupModal
|
||||
showClose={true}
|
||||
setupKey={setupKey?.key}
|
||||
showOnlyRoutingPeerOS={showOnlyRoutingPeerOS}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={successModal}
|
||||
onOpenChange={(open) => {
|
||||
@@ -117,11 +139,10 @@ export default function SetupKeyModal({
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
data-cy={"setup-key-copy"}
|
||||
onClick={() => copy(copyMessage)}
|
||||
onClick={() => setInstallModal(true)}
|
||||
>
|
||||
<CopyIcon size={14} />
|
||||
Copy to clipboard
|
||||
<DownloadIcon size={14} />
|
||||
Install NetBird
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
@@ -133,17 +154,23 @@ export default function SetupKeyModal({
|
||||
|
||||
type ModalProps = {
|
||||
onSuccess?: (setupKey: SetupKey) => void;
|
||||
predefinedName?: string;
|
||||
};
|
||||
|
||||
export function SetupKeyModalContent({ onSuccess }: Readonly<ModalProps>) {
|
||||
export function SetupKeyModalContent({
|
||||
onSuccess,
|
||||
predefinedName = "",
|
||||
}: Readonly<ModalProps>) {
|
||||
const setupKeyRequest = useApiCall<SetupKey>("/setup-keys", true);
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [name, setName] = useState(predefinedName);
|
||||
const [reusable, setReusable] = useState(false);
|
||||
const [usageLimit, setUsageLimit] = useState("");
|
||||
const [expiresIn, setExpiresIn] = useState("7");
|
||||
const [ephemeralPeers, setEphemeralPeers] = useState(false);
|
||||
const [allowExtraDNSLabels, setAllowExtraDNSLabels] = useState(false);
|
||||
|
||||
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
|
||||
useGroupHelper({
|
||||
initial: [],
|
||||
@@ -175,6 +202,7 @@ export function SetupKeyModalContent({ onSuccess }: Readonly<ModalProps>) {
|
||||
auto_groups: groups.map((group) => group.id),
|
||||
usage_limit: reusable ? parseInt(usageLimit) : 1,
|
||||
ephemeral: ephemeralPeers,
|
||||
allow_extra_dns_labels: allowExtraDNSLabels,
|
||||
})
|
||||
.then((setupKey) => {
|
||||
onSuccess && onSuccess(setupKey);
|
||||
@@ -198,6 +226,7 @@ export function SetupKeyModalContent({ onSuccess }: Readonly<ModalProps>) {
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-6 flex flex-col gap-8"}>
|
||||
{/* Name Field */}
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<HelpText>Set an easily identifiable name for your key</HelpText>
|
||||
@@ -208,6 +237,8 @@ export function SetupKeyModalContent({ onSuccess }: Readonly<ModalProps>) {
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reusable Toggle */}
|
||||
<div>
|
||||
<FancyToggleSwitch
|
||||
value={reusable}
|
||||
@@ -222,6 +253,7 @@ export function SetupKeyModalContent({ onSuccess }: Readonly<ModalProps>) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage Limit */}
|
||||
<div className={cn("flex justify-between", !reusable && "opacity-50")}>
|
||||
<div>
|
||||
<Label>Usage limit</Label>
|
||||
@@ -246,11 +278,13 @@ export function SetupKeyModalContent({ onSuccess }: Readonly<ModalProps>) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expires in Days */}
|
||||
<div className={"flex justify-between"}>
|
||||
<div>
|
||||
<Label>Expires in</Label>
|
||||
<HelpText>
|
||||
Days until the key expires. <br />
|
||||
Days until the key expires.
|
||||
<br />
|
||||
Leave empty for no expiration.
|
||||
</HelpText>
|
||||
</div>
|
||||
@@ -270,6 +304,7 @@ export function SetupKeyModalContent({ onSuccess }: Readonly<ModalProps>) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ephemeral Peers Toggle */}
|
||||
<div>
|
||||
<FancyToggleSwitch
|
||||
value={ephemeralPeers}
|
||||
@@ -285,6 +320,25 @@ export function SetupKeyModalContent({ onSuccess }: Readonly<ModalProps>) {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Allow Extra DNS Labels Toggle */}
|
||||
<div>
|
||||
<FancyToggleSwitch
|
||||
value={allowExtraDNSLabels}
|
||||
onChange={setAllowExtraDNSLabels}
|
||||
label={
|
||||
<>
|
||||
<GlobeIcon size={15} />
|
||||
Allow Extra DNS Labels
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Enable multiple subdomain labels when enrolling peers (e.g., host.dev.example.com)."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto-Assigned Groups */}
|
||||
<div>
|
||||
<Label>Auto-assigned groups</Label>
|
||||
<HelpText>
|
||||
@@ -299,6 +353,7 @@ export function SetupKeyModalContent({ onSuccess }: Readonly<ModalProps>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
|
||||
57
src/modules/setup-keys/SetupKeyStatusCell.tsx
Normal file
57
src/modules/setup-keys/SetupKeyStatusCell.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import Badge from "@components/Badge";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { GlobeIcon, HelpCircle, PowerOffIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
|
||||
type Props = {
|
||||
setupKey: SetupKey;
|
||||
};
|
||||
export default function SetupKeyStatusCell({ setupKey }: Readonly<Props>) {
|
||||
return (
|
||||
<div className={"flex gap-4"}>
|
||||
{setupKey?.ephemeral && <Ephemeral />}
|
||||
{setupKey?.allow_extra_dns_labels && <AllowExtraDNSLabels />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AllowExtraDNSLabels = () => {
|
||||
return (
|
||||
<FullTooltip
|
||||
interactive={false}
|
||||
content={
|
||||
<div className="max-w-xs text-xs">
|
||||
Allow multiple DNS labels in the peer name (e.g. <br />
|
||||
host.europe.netbird.io.)
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Badge variant="gray">
|
||||
<GlobeIcon size={12} className={"shrink-0"} />
|
||||
Extra DNS Labels
|
||||
<HelpCircle size={12} />
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const Ephemeral = () => {
|
||||
return (
|
||||
<FullTooltip
|
||||
interactive={false}
|
||||
content={
|
||||
<div className={"max-w-xs text-xs"}>
|
||||
Peers that are offline for over 10 minutes will be removed
|
||||
automatically.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Badge variant={"gray"}>
|
||||
<PowerOffIcon size={12} className={"shrink-0 text-yellow-400"} />
|
||||
Ephemeral
|
||||
<HelpCircle size={12} />
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
@@ -20,10 +20,10 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
import ExpirationDateRow from "@/modules/common-table-rows/ExpirationDateRow";
|
||||
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
|
||||
import SetupKeyActionCell from "@/modules/setup-keys/SetupKeyActionCell";
|
||||
import SetupKeyEphemeralCell from "@/modules/setup-keys/SetupKeyEphemeralCell";
|
||||
import SetupKeyGroupsCell from "@/modules/setup-keys/SetupKeyGroupsCell";
|
||||
import SetupKeyModal from "@/modules/setup-keys/SetupKeyModal";
|
||||
import SetupKeyNameCell from "@/modules/setup-keys/SetupKeyNameCell";
|
||||
import SetupKeyStatusCell from "@/modules/setup-keys/SetupKeyStatusCell";
|
||||
import SetupKeyUsageCell from "@/modules/setup-keys/SetupKeyUsageCell";
|
||||
|
||||
export const SetupKeysTableColumns: ColumnDef<SetupKey>[] = [
|
||||
@@ -82,15 +82,7 @@ export const SetupKeysTableColumns: ColumnDef<SetupKey>[] = [
|
||||
},
|
||||
cell: ({ row }) => <SetupKeyGroupsCell setupKey={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "ephemeral",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Ephemeral</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<SetupKeyEphemeralCell ephemeral={row.original.ephemeral} />
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "expires",
|
||||
header: ({ column }) => {
|
||||
@@ -106,7 +98,12 @@ export const SetupKeysTableColumns: ColumnDef<SetupKey>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => "",
|
||||
cell: ({ row }) => <SetupKeyStatusCell setupKey={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
|
||||
@@ -9,8 +9,17 @@ import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { RoutingPeerSetupKeyInfo } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
export default function DockerTab() {
|
||||
type Props = {
|
||||
setupKey?: string;
|
||||
showSetupKeyInfo?: boolean;
|
||||
};
|
||||
|
||||
export default function DockerTab({
|
||||
setupKey,
|
||||
showSetupKeyInfo = false,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<TabsContent value={String(OperatingSystem.DOCKER)}>
|
||||
<TabsContentPadding>
|
||||
@@ -35,14 +44,20 @@ export default function DockerTab() {
|
||||
</div>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2}>
|
||||
<p>Run NetBird container</p>
|
||||
<p>
|
||||
Run NetBird container
|
||||
{showSetupKeyInfo && <RoutingPeerSetupKeyInfo />}
|
||||
</p>
|
||||
<Code>
|
||||
<Code.Line>docker run --rm -d \</Code.Line>
|
||||
<Code.Line> --cap-add=NET_ADMIN \</Code.Line>
|
||||
<Code.Line>
|
||||
{" "}
|
||||
-e NB_SETUP_KEY=
|
||||
<span className={"text-netbird"}>SETUP_KEY</span> \
|
||||
<span className={"text-netbird"}>
|
||||
{setupKey ?? "SETUP_KEY"}
|
||||
</span>{" "}
|
||||
\
|
||||
</Code.Line>
|
||||
<Code.Line> -v netbird-client:/etc/netbird \</Code.Line>
|
||||
{GRPC_API_ORIGIN && (
|
||||
|
||||
@@ -13,8 +13,20 @@ import { getNetBirdUpCommand } from "@utils/netbird";
|
||||
import { TerminalSquareIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import {
|
||||
RoutingPeerSetupKeyInfo,
|
||||
SetupKeyParameter,
|
||||
} from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
export default function LinuxTab() {
|
||||
type Props = {
|
||||
setupKey?: string;
|
||||
showSetupKeyInfo?: boolean;
|
||||
};
|
||||
|
||||
export default function LinuxTab({
|
||||
setupKey,
|
||||
showSetupKeyInfo = false,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<TabsContent value={String(OperatingSystem.LINUX)}>
|
||||
<TabsContentPadding>
|
||||
@@ -27,9 +39,15 @@ export default function LinuxTab() {
|
||||
<Code>curl -fsSL https://pkgs.netbird.io/install.sh | sh</Code>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2} line={false}>
|
||||
<p>Run NetBird and log in the browser</p>
|
||||
<p>
|
||||
Run NetBird {!setupKey && "and log in the browser"}
|
||||
{showSetupKeyInfo && <RoutingPeerSetupKeyInfo />}
|
||||
</p>
|
||||
<Code>
|
||||
<Code.Line>{getNetBirdUpCommand()}</Code.Line>
|
||||
<Code.Line>
|
||||
{getNetBirdUpCommand()}
|
||||
<SetupKeyParameter setupKey={setupKey} />
|
||||
</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
@@ -78,9 +96,15 @@ export default function LinuxTab() {
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={3} line={false}>
|
||||
<p>Run NetBird and log in the browser</p>
|
||||
<p>
|
||||
Run NetBird {!setupKey && "and log in the browser"}
|
||||
{showSetupKeyInfo && <RoutingPeerSetupKeyInfo />}
|
||||
</p>
|
||||
<Code>
|
||||
<Code.Line>{getNetBirdUpCommand()}</Code.Line>
|
||||
<Code.Line>
|
||||
{getNetBirdUpCommand()}
|
||||
<SetupKeyParameter setupKey={setupKey} />
|
||||
</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user