Compare commits

...

86 Commits

Author SHA1 Message Date
Misha Bragin
7a29dac01c Add single line installer command for Mac and Linux (#162)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-04-04 14:20:02 +02:00
pascal-fischer
444e9ec44a fetch userID from user list and not oidc by default (#161) 2023-04-03 16:52:42 +02:00
pascal-fischer
dff0313f82 Feature/pat support (#157)
* Add working UI + API calls [missing token popup]

* use popup view to add new token

* show userID if user name not available

* switch from description to name

* show "Me" instead of own name

* removed created_by column

* update add token explanation

* use object instead of plain text for token create response

* some style changes

* disable information button for tokens

* last_used can contain nil

* fix delete popups

* lower case letters for dates

* add activity and fix visibility

* show settings tab for non admins

* remove spaces on top of setting tabs

* fix copy button size and position

* fix list footers

* continue merge changes to new files
2023-04-03 12:29:40 +02:00
Maycon Santos
11fbfb336a Clean last estimated name (#160) 2023-04-03 12:16:12 +02:00
Maycon Santos
4a0ae8f27d Add missing config change (#159) 2023-04-01 21:58:46 +02:00
Maycon Santos
9a72d8b0c4 Allow defining API token source (#158)
On many IDP providers, the access token
 is used to access the IDP's own API

 With these changes, we allow users to define the proper token to be used for
 management API calls
2023-04-01 19:44:28 +02:00
Misha Bragin
8e038cf242 Make table headers font-weight normal (#156) 2023-03-27 15:59:18 +02:00
Misha Bragin
5bd94eff56 Fix add peer popup tab layout on mobile (#154)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-03-23 12:10:27 +01:00
Misha Bragin
77f065b093 Update theme border radius (#152) 2023-03-22 16:56:36 +01:00
Misha Bragin
a4d55cfb90 Fix settings modal styles (#151) 2023-03-22 16:14:49 +01:00
Misha Bragin
cfd4c9075b Add onboarding steps (#150) 2023-03-22 15:21:52 +01:00
braginini
962180030a Fix activity docs link 2023-03-15 14:20:48 +01:00
Misha Bragin
485e1e8d79 Add more references to docs (#149) 2023-03-15 14:18:22 +01:00
Givi Khojanashvili
b11007b29f Add policy add activities (#147)
Related changes for netbirdio/netbird#700
2023-03-13 10:58:34 +01:00
Maycon Santos
bce75c1ca9 Disable banner (#146)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-03-02 15:55:31 +01:00
Maycon Santos
0c09992b38 Use user.is_current (#145)
prevent update for own user

use the is_current for label

removed unused imports
2023-03-01 22:50:26 +01:00
Maycon Santos
86b12f30d2 Use new windows release URL (#144)
removed latestVersion logic and environment support
2023-03-01 15:00:36 +01:00
Maycon Santos
d3e34d8448 Use single page size helper with new larger values (#143)
Reduce duplicated code with single helper for pagination

On larger deployments we should allow for larger pages

fix popover key issue and vertical spacing

update deprecated keys
2023-02-28 16:32:12 +01:00
Misha Bragin
76083168f6 Log error when activity event is not handled (#142) 2023-02-28 11:22:40 +01:00
Misha Bragin
a54b3687ae Display user in the user role update event (#141)
Handling of the "User role updated" event (user.role.update) 
was missing in the Activity tab.
2023-02-27 15:27:00 +01:00
Zoltan Papp
25f154dc83 Capitalize first letter of OS (#140) 2023-02-27 14:54:39 +01:00
pascal-fischer
f3c7d877f8 Add additional step in the Mac installation instructions to start the daemon (#138) 2023-02-22 20:55:36 +01:00
Misha Bragin
7cea7e7f54 Format individual peer login expiration event (#139)
Events peer.login.expiration.disable and peer.login.expiration.enable
are handled in the Activity tab now and properly displayed.
2023-02-22 20:54:56 +01:00
Misha Bragin
aaa351635f Add peer expiration setting confirmation modal (#137)
Add a confirmation dialog to notify a user of possible
consequences of the peer login expiration enabling/disabling.
2023-02-21 08:47:14 +01:00
Misha Bragin
379ff5486e Account settings view (#136)
New Settings tab added to the dashboard.
It is possible to enable or disable peer login expiration globally for an account.
As well as defining the expiration time period.
2023-02-20 08:57:24 +01:00
Misha Bragin
8bcd9918e2 Fix expires in input filed of the setup key (#135) 2023-02-17 14:30:22 +01:00
Misha Bragin
044ccd0ce6 Display login expiration activities (#134) 2023-02-16 15:36:45 +01:00
braginini
ab09ca3697 Display API error in peers view 2023-02-16 13:03:39 +01:00
Misha Bragin
1644ed5dce Add peer login expiration tooltip (#133) 2023-02-16 12:47:35 +01:00
Misha Bragin
cea459792f Add peer login expiration (#132)
Display if peer login has expired in the Peers table
Enable/Disable peer login expiration
2023-02-16 12:18:15 +01:00
Maycon Santos
a402680816 Disallow all crawlers and dashboard indexing (#131)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-02-10 18:45:11 +01:00
Givi Khojanashvili
e57e5b726d Feat add custom id claim (#129)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Fix management API endpoint ENV var. Format README.

* Add and use id_current user flag

* Use mix of the new and old methods to detect current user.
2023-02-03 21:48:03 +01:00
Misha Bragin
2c4ada0ad8 Use peerID in the Routes view (#130)
Relates to the netbirdio/netbird PR
Use Peer.ID instead of Peer.Key as peer identifier (#664)
2023-02-03 10:34:43 +01:00
Misha Bragin
8195587c85 Handle additional activity events (#128)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-01-31 13:56:46 +01:00
Maycon Santos
adf6e1e71f Use ID token payload when oidcUser is nil (#127)
With some IDPs like MS Azure the
oidcUser is not being set, so we can fallback to id token
to validate UI and current user
2023-01-24 09:10:14 +01:00
Maycon Santos
b733a186ae Remove console.log in peers page (#125)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-01-19 14:55:42 +01:00
Maycon Santos
5d901470c2 Feature/dns settings (#126)
Add DNS settings view to the DNS tab.

Split the page into sub-tabs for Nameservers and DNS settings

Added API calls to the new DNS settings API
2023-01-18 18:12:29 +01:00
Misha Bragin
29ab28847d Add Events view (#119) 2023-01-02 17:29:11 +01:00
Maycon Santos
0361825e04 Add peer's last seem time on popover (#122)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2022-12-16 19:37:10 +01:00
Moath Qasim
2fa33ec06a Fix MacOS custom netbird up command (#121)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2022-12-11 18:54:48 +01:00
Maycon Santos
c677eeaae4 Add distribution groups to Network routes (#118)
Some checks failed
build and push / build_n_push (push) Has been cancelled
Users can add distribution groups to network routes

Groups can be added to individual network routes or to all routes in the group

Adding a new group in the modal is restricted to individual network route operations
2022-12-08 17:24:34 +01:00
Maycon Santos
7fb4b0b145 Update actions versions (#120)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2022-12-08 13:22:07 +01:00
dependabot[bot]
57f60a2fbf Bump loader-utils from 2.0.2 to 2.0.4 (#109)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 12:06:54 +01:00
dependabot[bot]
ec949da416 Bump minimatch and recursive-readdir (#106)
Bumps [minimatch](https://github.com/isaacs/minimatch) and [recursive-readdir](https://github.com/jergason/recursive-readdir). These dependencies needed to be updated together.

Updates `minimatch` from 3.0.4 to 3.1.2
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.1.2)

Updates `recursive-readdir` from 2.2.2 to 2.2.3
- [Release notes](https://github.com/jergason/recursive-readdir/releases)
- [Changelog](https://github.com/jergason/recursive-readdir/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jergason/recursive-readdir/commits/v2.2.3)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-type: indirect
- dependency-name: recursive-readdir
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 12:06:28 +01:00
Misha Bragin
43710b8ada Add SetupKey usage limit (#117) 2022-12-05 13:10:21 +01:00
Maycon Santos
247665a846 Call users API on empty state (#116)
Validate if the users state is empty and issue a GET call to /api/users

We check if the call was issued and if not on tabs that already does such a call
2022-11-27 16:33:27 +01:00
Maycon Santos
010657c594 Fix DNS validations (#115)
Check if domains is not empty when primary is false

Validate domain and primary fields before submit

Display validation errors as message too

Reload groups on failure too

Display API call error

Added call to action message when nameserver is empty
2022-11-27 13:15:55 +01:00
Misha Bragin
2d911af97f Add DNS feature announcement (#114) 2022-11-25 18:37:55 +01:00
Misha Bragin
afdfed0160 Filter peers by groups (#113) 2022-11-25 18:26:00 +01:00
Misha Bragin
6b86da3716 Copy IP or DNS on the peers tab (#112)
Copy Peer IP or DNS in the Address column in Peers table
2022-11-25 17:23:35 +01:00
Maycon Santos
425fac8e9c DNS (#101)
Added DNS tab for managing Nameservers.
Users will be able to add multiple nameservers 
and set distribution groups that dictate to which peers the settings will be applied.
With this PR we also got a set of group handlers that can be reused.
2022-11-25 15:55:26 +01:00
Misha Bragin
fa2413f937 Add FQDN of a peer to the peers table (#111) 2022-11-21 11:27:13 +01:00
Misha Bragin
feec057933 Don't show admin content to users with a "user" role (#108) 2022-11-15 17:58:06 +01:00
Enrico Renna
9127686df7 fix: apt-key deprecation warning/errors (#107) 2022-11-15 09:12:13 +01:00
Maycon Santos
479911ded8 Check for change in any field (#104)
* Check for change in any field

Name should not be required, specially for self-hosted

* proper email change validation
2022-11-12 15:29:16 +01:00
Misha Bragin
69dcd6fadd Handle Management API errors (#103) 2022-11-10 16:30:16 +01:00
Misha Bragin
0b7b34b490 Handle Forbidden Errors coming from management (#100) 2022-11-05 08:41:55 +01:00
braginini
fff93a3820 Improve spacing on email verification screen. 2022-10-27 09:50:58 +02:00
braginini
da21784c73 Improve email verification window messaging 2022-10-27 09:40:57 +02:00
braginini
2e03a39b3e Add user invites banner 2022-10-20 12:01:52 +02:00
Maycon Santos
8e626cdd96 Revert "bump @axa-fr/react-oidc to 6.9 to fix atob padding bug (#97)" (#99)
This reverts commit 957ff98cec.
2022-10-18 17:48:30 +02:00
braginini
472704ad59 Merge remote-tracking branch 'origin/main' 2022-10-18 11:49:53 +02:00
braginini
94c7288016 Enable invite logic 2022-10-18 11:49:43 +02:00
Jens L
957ff98cec bump @axa-fr/react-oidc to 6.9 to fix atob padding bug (#97)
* bump @axa-fr/react-oidc to 6.9 to fix atob padding bug

* add issuer to auth0AuthorityConfig
2022-10-17 23:54:21 +02:00
Jens L
80178f66c3 correctly handle JWTs without base64-padding (#96) 2022-10-15 17:34:40 +02:00
braginini
37324cbcfc Hide user invites feature 2022-10-14 11:42:21 +02:00
braginini
9dd362a8a4 Add wiretrustee.com domain to "hosted" variants 2022-10-13 18:27:52 +02:00
braginini
90605a2067 Enable invites only for the local or hosted version 2022-10-13 18:11:30 +02:00
Misha Bragin
18cfddbbe7 Support User Invites (#86)
This PR brings an "Invite User" button to the Users view
and a view to creating (inviting) a new user to the account.
This function calls the Management API POST /users
endpoint that creates a new user.
2022-10-13 18:01:32 +02:00
Maycon Santos
17e671200e Don't display announcement after user close it (#95)
We store the banner text as md5
and the state if user closed already
2022-10-13 17:45:51 +02:00
Maycon Santos
bb94342cc8 Support custom redirect callback URIS (#92) 2022-10-12 12:25:55 +02:00
Misha Bragin
b86cf8b99f Add setup key expiration property when creating new key (#90) 2022-10-06 17:03:09 +02:00
Maycon Santos
f472c06cbf skip analytics on monitoring 2022-10-03 12:27:51 +05:00
Maycon Santos
c58834309b Add hotjar integration (#89)
hotjar is only enabled if NETBIRD_HOTJAR_TRACK_ID is passed
2022-10-02 18:01:06 +05:00
Maycon Santos
75fdd3e17f Add new peer button and user message (#87) 2022-09-30 19:59:55 +02:00
braginini
568c5eccda Change tables key fields styling 2022-09-29 11:00:56 +02:00
braginini
363f226a1c Add me tag to the user view to identify the current user 2022-09-29 10:37:04 +02:00
braginini
bf447b1ada Fix user role display when clicking view 2022-09-29 10:26:02 +02:00
Maycon Santos
90cb05bd2d Parse access token for validation and use latestToke global (#85) 2022-09-27 12:33:55 +02:00
Misha Bragin
a98d6d9ce1 Display additional peer info (#84) 2022-09-26 18:40:06 +02:00
braginini
f83e39d734 Fix deprecated fields warning 2022-09-23 15:05:34 +02:00
braginini
52c1909229 Fix package json 2022-09-23 15:02:23 +02:00
Misha Bragin
f0d893c689 Support user role update (#82) 2022-09-23 14:21:59 +02:00
Misha Bragin
c8339c4be1 Peers auto-tagging with users and predefined groups (#77) 2022-09-22 10:19:53 +02:00
Misha Bragin
954d697b5f Add Access Token hook to return valid token (#81) 2022-09-19 13:11:07 +02:00
Maycon Santos
ace2bb61ef Fix popover for groups (#78)
* Fix popover for groups

Fixed all popover overlay when opening
update modal

also fixed group input size problem
 with peers update

* remove style display flex from form tittle
2022-09-15 22:24:20 +05:00
100 changed files with 10017 additions and 5998 deletions

View File

@@ -11,10 +11,10 @@ jobs:
build_n_push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: setup-node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: '16'
cache: 'npm'
@@ -27,26 +27,26 @@ jobs:
run: CI=false npm run build
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
-
name: Docker meta
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v4
with:
images: wiretrustee/dashboard
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
-
name: Docker build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
context: .
file: docker/Dockerfile

View File

@@ -36,24 +36,43 @@ Disclaimer. We believe that proper user management system is not a trivial task
use Auth0 service that covers all our needs (user management, social login, JTW for the management API).
Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
1. install [Docker](https://docs.docker.com/get-docker/)
2. register [Auth0](https://auth0.com/) account
3. running Wiretrustee UI Dashboard requires the following Auth0 environmental variables to be set (see docker command below):
1. Install [Docker](https://docs.docker.com/get-docker/)
2. Register [Auth0](https://auth0.com/) account
3. Running Wiretrustee UI Dashboard requires the following Auth0 environmental variables to be set (see docker command below):
```AUTH0_DOMAIN``` ```AUTH0_CLIENT_ID``` ```AUTH0_AUDIENCE```
`AUTH0_DOMAIN` `AUTH0_CLIENT_ID` `AUTH0_AUDIENCE`
To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react/01-login#configure-auth0) up until "Configure Allowed Web Origins"
4. Wiretrustee UI Dashboard uses Wiretrustee Management Service HTTP API, so setting ```WIRETRUSTEE_MGMT_API_ENDPOINT``` is required. Most likely it will be ```http://localhost:33071``` if you are hosting Management API on the same server.
4. Wiretrustee UI Dashboard uses Wiretrustee Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
5. Run docker container without SSL (Let's Encrypt):
```docker run -d --name wiretrustee-dashboard --rm -p 80:80 -p 443:443 -e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> -e AUTH0_CLIENT_ID=<SET YOUR CLIENT ID> -e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> -e WIRETRUSTEE_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> wiretrustee/dashboard:main```
```shell
docker run -d --name wiretrustee-dashboard \
--rm -p 80:80 -p 443:443 \
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
-e AUTH0_CLIENT_ID=<SET YOUR CLIENT ID> \
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
wiretrustee/dashboard:main
```
6. Run docker container with SSL (Let's Encrypt):
```docker run -d --name wiretrustee-dashboard --rm -p 80:80 -p 443:443 -e NGINX_SSL_PORT=443 -e LETSENCRYPT_DOMAIN=<YOUR PUBLIC DOMAIN> -e LETSENCRYPT_EMAIL=<YOUR EMAIL> -e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> -e AUTH0_CLIENT_ID=<SET YOUR CLEITN ID> -e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> -e WIRETRUSTEE_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> wiretrustee/dashboard:main```
```shell
docker run -d --name wiretrustee-dashboard \
--rm -p 80:80 -p 443:443 \
-e NGINX_SSL_PORT=443 \
-e LETSENCRYPT_DOMAIN=<YOUR PUBLIC DOMAIN> \
-e LETSENCRYPT_EMAIL=<YOUR EMAIL> \
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
-e AUTH0_CLIENT_ID=<SET YOUR CLEITN ID> \
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
wiretrustee/dashboard:main
```
## How to run local development
1. Install node 16
2. create and update the src/.local-config.json file. This file should contain values to be replaced from src/config.json
2. create and update the `src/.local-config.json` file. This file should contain values to be replaced from `src/config.json`
3. run `npm install`
4. run `npm run start dev`
4. run `npm run start dev`

View File

@@ -44,19 +44,21 @@ fi
export AUTH_AUTHORITY=${AUTH_AUTHORITY:-https://$AUTH0_DOMAIN}
export AUTH_CLIENT_ID=${AUTH_CLIENT_ID:-$AUTH0_CLIENT_ID}
export AUTH_AUDIENCE=${AUTH_AUDIENCE:-$AUTH0_AUDIENCE}
export AUTH_REDIRECT_URI=${AUTH_REDIRECT_URI}
export AUTH_SILENT_REDIRECT_URI=${AUTH_SILENT_REDIRECT_URI}
export USE_AUTH0=${USE_AUTH0:-true}
export AUTH_SUPPORTED_SCOPES=${AUTH_SUPPORTED_SCOPES:-openid profile email api offline_access email_verified}
export NETBIRD_MGMT_API_ENDPOINT=$(echo $NETBIRD_MGMT_API_ENDPOINT | sed -E 's/(:80|:443)$//')
export NETBIRD_MGMT_GRPC_API_ENDPOINT=${NETBIRD_MGMT_GRPC_API_ENDPOINT}
export NETBIRD_HOTJAR_TRACK_ID=${NETBIRD_HOTJAR_TRACK_ID}
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
REPO="https://github.com/netbirdio/netbird/"
# this command will fetch the latest release e.g. v0.6.3
export NETBIRD_LATEST_VERSION=$(basename $(curl -fs -o/dev/null -w %{redirect_url} ${REPO}releases/latest))
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
# replace ENVs in the config
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_LATEST_VERSION"
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE"
MAIN_JS=$(find /usr/share/nginx/html/static/js/main.*js)
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
cp "$MAIN_JS" "$MAIN_JS".copy

8033
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^4.7.0",
"@ant-design/icons": "^4.8.0",
"@axa-fr/react-oidc": "^5.14.0",
"@headlessui/react": "^1.5.0",
"@heroicons/react": "^1.0.4",
@@ -18,7 +18,7 @@
"@types/react-redux": "^7.1.24",
"@types/react-router-dom": "^5.3.3",
"@types/styled-components": "^5.1.25",
"antd": "^4.20.6",
"antd": "^5.3.1",
"autoprefixer": "^10.4.4",
"axios": "^0.27.2",
"cidr-regex": "^3.1.1",
@@ -27,11 +27,15 @@
"highlight.js": "^11.2.0",
"history": "^5.0.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"postcss": "^8.4.12",
"prop-types": "^15.7.2",
"punycode": "^2.1.1",
"rc-overflow": "^1.2.8",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.1.0",
"react-hotjar": "^5.1.0",
"react-redux": "^8.0.2",
"react-router-dom": "^5.3.3",
"react-scripts": "^5.0.1",
@@ -42,6 +46,7 @@
"redux-saga": "^1.1.3",
"styled-components": "^5.3.5",
"tailwindcss": "^3.0.23",
"ts-md5": "^1.3.1",
"typesafe-actions": "^5.1.0",
"typescript": "^4.6.4",
"web-vitals": "^2.1.4"

View File

@@ -5,6 +5,7 @@
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="robots" content="noindex">
<meta
name="description"
content="NetBird Management Dashboard"

View File

@@ -1,3 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Disallow: /

View File

@@ -1,23 +1,41 @@
import React, {useEffect, useState} from 'react';
import React, {useEffect, useRef, useState} from 'react';
import {Provider} from "react-redux";
import {Redirect, Route, Switch} from 'react-router-dom';
import Navbar from './components/Navbar';
import Peers from './views/Peers';
import FooterComponent from './components/FooterComponent';
import SetupKeys from "./views/SetupKeys";
import AddPeer from "./views/AddPeer";
import Users from './views/Users';
import AccessControl from './views/AccessControl';
import Routes from './views/Routes';
import {apiClient, store} from "./store";
import {hotjar} from 'react-hotjar';
import {getConfig} from "./config";
import Banner from "./components/Banner";
import {store} from "./store";
import { Col, Layout, Row} from 'antd';
import {Col, ConfigProvider, Layout, Row} from "antd";
import {Container} from "./components/Container";
import {withOidcSecure} from '@axa-fr/react-oidc';
import Navbar from "./components/Navbar";
import {Redirect, Route, Switch} from "react-router-dom";
import {withOidcSecure} from "@axa-fr/react-oidc";
import Peers from "./views/Peers";
import Routes from "./views/Routes";
import AddPeer from "./views/AddPeer";
import SetupKeys from "./views/SetupKeys";
import AccessControl from "./views/AccessControl";
import Users from "./views/Users";
import FooterComponent from "./components/FooterComponent";
import {useGetTokenSilently, useTokenSource} from "./utils/token";
import {User} from "./store/user/types";
import {SecureLoading} from "./components/Loading";
import DNS from "./views/DNS";
import Activity from "./views/Activity";
import Settings from "./views/Settings";
const {Header, Content} = Layout;
function App() {
const run = useRef(false)
const [show, setShow] = useState(false)
const {hotjarTrackID,tokenSource} = getConfig();
useTokenSource(tokenSource)
const {getTokenSilently} = useGetTokenSilently();
// @ts-ignore
if (hotjarTrackID && window._DATADOG_SYNTHETICS_BROWSER === undefined) {
hotjar.initialize(hotjarTrackID, 6);
}
const [isOpen, setIsOpen] = useState(false);
@@ -25,7 +43,6 @@ function App() {
const hideMenu = () => {
if (window.innerWidth > 768 && isOpen) {
setIsOpen(false);
console.log('i resized');
}
};
@@ -34,49 +51,82 @@ function App() {
return () => {
window.removeEventListener('resize', hideMenu);
};
});
}, []);
useEffect(() => {
if (!run.current) {
run.current = true
apiClient.request<User[]>('GET', `/api/users`, {getAccessTokenSilently: getTokenSilently})
.then(() => {
setShow(true)
})
.catch(e => {
setShow(true)
console.log(e)
})
}
}, [getTokenSilently])
return (
<Provider store={store}>
<Layout>
<Banner/>
<Header className="header" style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-around",
alignContent: "center"
}}>
<Row justify="space-around" align="middle">
<Col span={24}>
<Container>
<Navbar/>
</Container>
</Col>
</Row>
</Header>
<Content style={{ minHeight: "100vh"}}>
<Switch>
<Route
exact
path="/"
render={() => {
return (
<Redirect to="/peers"/>
)
}}
/>
<Route path='/peers' exact component={withOidcSecure(Peers)}/>
<Route path="/add-peer" component={withOidcSecure(AddPeer)}/>
<Route path="/setup-keys" component={withOidcSecure(SetupKeys)}/>
<Route path="/acls" component={withOidcSecure(AccessControl)}/>
<Route path="/routes" component={withOidcSecure(Routes)}/>
<Route path="/users" component={withOidcSecure(Users)}/>
</Switch>
</Content>
<FooterComponent/>
</Layout>
</Provider>
);
<>
<ConfigProvider
theme={{
token: {
borderRadius: 4,
colorPrimary: "#1890ff",
fontFamily: "Arial"
},
}}
>
<Provider store={store}>
{!show && <SecureLoading padding="3em" width={50} height={50}/>}
{show &&
<Layout>
<Banner/>
<Header className="header" style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-around",
alignContent: "center"
}}>
<Row justify="space-around" align="middle">
<Col span={24}>
<Container>
<Navbar/>
</Container>
</Col>
</Row>
</Header>
<Content style={{minHeight: "100vh"}}>
<Switch>
<Route
exact
path="/"
render={() => {
return (
<Redirect to="/peers"/>
)
}}
/>
<Route path='/peers' exact component={withOidcSecure(Peers)}/>
<Route path="/setup-keys" component={withOidcSecure(SetupKeys)}/>
<Route path="/acls" component={withOidcSecure(AccessControl)}/>
<Route path="/routes" component={withOidcSecure(Routes)}/>
<Route path="/users" component={withOidcSecure(Users)}/>
<Route path="/dns" component={withOidcSecure(DNS)}/>
<Route path="/activity" component={withOidcSecure(Activity)}/>
<Route path="/settings" component={withOidcSecure(Settings)}/>
</Switch>
</Content>
<FooterComponent/>
</Layout>
}
</Provider>
</ConfigProvider>
</>
)
}
export default App;

View File

@@ -16,7 +16,7 @@ const AccessControlModalGroups:React.FC<Props> = ({data, title, visible, onCance
return (
<>
<Modal title={title} visible={isModalVisible} onCancel={() => onCancel()} footer={null}>
<Modal title={title} open={isModalVisible} onCancel={() => onCancel()} footer={null}>
<List
itemLayout="horizontal"
dataSource={data as Group[] | undefined}

View File

@@ -1,26 +1,32 @@
import React, {useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import { actions as ruleActions } from '../store/rule';
import {actions as ruleActions} from '../store/rule';
import {
Button,
Col,
Row,
Typography,
Divider,
Drawer,
Form,
Input,
Radio,
RadioChangeEvent,
Row,
Select,
Space,
Switch,
Button, Drawer, Form, Divider, Select, Tag, Radio, RadioChangeEvent
Tag,
Typography
} from "antd";
import {ArrowRightOutlined, CheckOutlined, CloseOutlined, FlagFilled, QuestionCircleFilled} from "@ant-design/icons";
import type { CustomTagProps } from 'rc-select/lib/BaseSelect'
import {CloseOutlined, FlagFilled, QuestionCircleFilled} from "@ant-design/icons";
import type {CustomTagProps} from 'rc-select/lib/BaseSelect'
import {Rule, RuleToSave} from "../store/rule/types";
import { uniq } from "lodash"
import {uniq} from "lodash"
import {Header} from "antd/es/layout/layout";
import {RuleObject} from "antd/lib/form";
import {useOidcAccessToken} from "@axa-fr/react-oidc";
import {useGetTokenSilently} from "../utils/token";
const { Paragraph } = Typography;
const { Option } = Select;
const {Paragraph} = Typography;
const {Option} = Select;
interface FormRule extends Rule {
tagSourceGroups: string[]
@@ -28,11 +34,11 @@ interface FormRule extends Rule {
}
const AccessControlNew = () => {
const {accessToken} = useOidcAccessToken()
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const setupNewRuleVisible = useSelector((state: RootState) => state.rule.setupNewRuleVisible)
const groups = useSelector((state: RootState) => state.group.data)
const rule = useSelector((state: RootState) => state.rule.rule)
const groups = useSelector((state: RootState) => state.group.data)
const rule = useSelector((state: RootState) => state.rule.rule)
const savedRule = useSelector((state: RootState) => state.rule.savedRule)
const [editName, setEditName] = useState(false)
@@ -72,7 +78,7 @@ const AccessControlNew = () => {
setTagGroups(groups?.map(g => g.name) || [])
}, [groups])
const createRuleToSave = ():RuleToSave => {
const createRuleToSave = (): RuleToSave => {
const sources = groups?.filter(g => formRule.tagSourceGroups.includes(g.name)).map(g => g.id || '') || []
const destinations = groups?.filter(g => formRule.tagDestinationGroups.includes(g.name)).map(g => g.id || '') || []
const sourcesNoId = formRule.tagSourceGroups.filter(s => !tagGroups.includes(s))
@@ -96,14 +102,17 @@ const AccessControlNew = () => {
form.validateFields()
.then((values) => {
const ruleToSave = createRuleToSave()
dispatch(ruleActions.saveRule.request({getAccessTokenSilently:accessToken, payload: ruleToSave}))
dispatch(ruleActions.saveRule.request({
getAccessTokenSilently: getTokenSilently,
payload: ruleToSave
}))
})
.catch((errorInfo) => {
console.log('errorInfo', errorInfo)
});
};
const setVisibleNewRule = (status:boolean) => {
const setVisibleNewRule = (status: boolean) => {
dispatch(ruleActions.setSetupNewRuleVisible(status));
}
@@ -121,7 +130,7 @@ const AccessControlNew = () => {
setVisibleNewRule(false)
}
const onChange = (data:any) => {
const onChange = (data: any) => {
setFormRule({...formRule, ...data})
}
@@ -139,7 +148,7 @@ const AccessControlNew = () => {
})
};
const handleChangeDisabled = ({ target: { value } }: RadioChangeEvent) => {
const handleChangeDisabled = ({target: {value}}: RadioChangeEvent) => {
setFormRule({
...formRule,
disabled: value
@@ -147,7 +156,7 @@ const AccessControlNew = () => {
};
const tagRender = (props: CustomTagProps) => {
const { label, value, closable, onClose } = props;
const {label, value, closable, onClose} = props;
const onPreventMouseDown = (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
@@ -159,7 +168,7 @@ const AccessControlNew = () => {
onMouseDown={onPreventMouseDown}
closable={closable}
onClose={onClose}
style={{ marginRight: 3 }}
style={{marginRight: 3}}
>
<strong>{value}</strong>
</Tag>
@@ -169,12 +178,12 @@ const AccessControlNew = () => {
const optionRender = (label: string) => {
let peersCount = ''
const g = groups.find(_g => _g.name === label)
if (g) peersCount = ` - ${g.peers_count || 0} ${(!g.peers_count || parseInt(g.peers_count) !== 1) ? 'peers' : 'peer'} `
if (g) peersCount = ` - ${g.peers_count || 0} ${(!g.peers_count || parseInt(g.peers_count) !== 1) ? 'peers' : 'peer'} `
return (
<>
<Tag
color="blue"
style={{ marginRight: 3 }}
style={{marginRight: 3}}
>
<strong>{label}</strong>
</Tag>
@@ -186,25 +195,27 @@ const AccessControlNew = () => {
const dropDownRender = (menu: React.ReactElement) => (
<>
{menu}
<Divider style={{ margin: '8px 0' }} />
<Divider style={{margin: '8px 0'}}/>
<Row style={{padding: '0 8px 4px'}}>
<Col flex="auto">
<span style={{color: "#9CA3AF"}}>Add new group by pressing "Enter"</span>
</Col>
<Col flex="none">
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.70455 7.19176V5.89915H10.3949C10.7727 5.89915 11.1174 5.80634 11.429 5.62074C11.7405 5.43513 11.9875 5.18655 12.1697 4.875C12.3554 4.56345 12.4482 4.21875 12.4482 3.84091C12.4482 3.46307 12.3554 3.12003 12.1697 2.81179C11.9841 2.50024 11.7356 2.25166 11.424 2.06605C11.1158 1.88044 10.7727 1.78764 10.3949 1.78764H9.83807V0.5H10.3949C11.0114 0.5 11.5715 0.650805 12.0753 0.952414C12.5791 1.25402 12.9818 1.65672 13.2834 2.16051C13.585 2.6643 13.7358 3.22443 13.7358 3.84091C13.7358 4.30161 13.648 4.73414 13.4723 5.13849C13.3 5.54285 13.0613 5.89915 12.7564 6.20739C12.4515 6.51562 12.0968 6.75758 11.6925 6.93324C11.2881 7.10559 10.8556 7.19176 10.3949 7.19176H1.70455ZM4.90128 11.0646L0.382102 6.54545L4.90128 2.02628L5.79119 2.91619L2.15696 6.54545L5.79119 10.1747L4.90128 11.0646Z" fill="#9CA3AF"/>
<path
d="M1.70455 7.19176V5.89915H10.3949C10.7727 5.89915 11.1174 5.80634 11.429 5.62074C11.7405 5.43513 11.9875 5.18655 12.1697 4.875C12.3554 4.56345 12.4482 4.21875 12.4482 3.84091C12.4482 3.46307 12.3554 3.12003 12.1697 2.81179C11.9841 2.50024 11.7356 2.25166 11.424 2.06605C11.1158 1.88044 10.7727 1.78764 10.3949 1.78764H9.83807V0.5H10.3949C11.0114 0.5 11.5715 0.650805 12.0753 0.952414C12.5791 1.25402 12.9818 1.65672 13.2834 2.16051C13.585 2.6643 13.7358 3.22443 13.7358 3.84091C13.7358 4.30161 13.648 4.73414 13.4723 5.13849C13.3 5.54285 13.0613 5.89915 12.7564 6.20739C12.4515 6.51562 12.0968 6.75758 11.6925 6.93324C11.2881 7.10559 10.8556 7.19176 10.3949 7.19176H1.70455ZM4.90128 11.0646L0.382102 6.54545L4.90128 2.02628L5.79119 2.91619L2.15696 6.54545L5.79119 10.1747L4.90128 11.0646Z"
fill="#9CA3AF"/>
</svg>
</Col>
</Row>
</>
)
const toggleEditName = (status:boolean) => {
const toggleEditName = (status: boolean) => {
setEditName(status);
}
const toggleEditDescription = (status:boolean) => {
const toggleEditDescription = (status: boolean) => {
setEditDescription(status);
}
@@ -217,10 +228,10 @@ const AccessControlNew = () => {
const selectValidator = (_: RuleObject, value: string[]) => {
let hasSpaceNamed = []
if (!value.length) {
return Promise.reject(new Error("Please enter ate least one group"))
return Promise.reject(new Error("Please enter at least one group"))
}
value.forEach(function(v: string) {
value.forEach(function (v: string) {
if (!v.trim().length) {
hasSpaceNamed.push(v)
}
@@ -248,7 +259,8 @@ const AccessControlNew = () => {
footer={
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button onClick={onCancel} disabled={savedRule.loading}>Cancel</Button>
<Button type="primary" disabled={savedRule.loading} onClick={handleFormSubmit}>{`${formRule.id ? 'Save' : 'Create'}`}</Button>
<Button type="primary" disabled={savedRule.loading}
onClick={handleFormSubmit}>{`${formRule.id ? 'Save' : 'Create'}`}</Button>
</Space>
}
>
@@ -258,37 +270,51 @@ const AccessControlNew = () => {
<Header style={{margin: "-32px -24px 20px -24px", padding: "24px 24px 0 24px"}}>
<Row align="top">
<Col flex="none" style={{display: "flex"}}>
{!editName && !editDescription && formRule.id &&
{!editName && !editDescription && formRule.id &&
<button type="button" aria-label="Close" className="ant-drawer-close"
style={{paddingTop: 3}}
onClick={onCancel}>
<span role="img" aria-label="close" className="anticon anticon-close">
<span role="img" aria-label="close"
className="anticon anticon-close">
<CloseOutlined size={16}/>
</span>
</button>
}
</Col>
<Col flex="auto">
{ !editName && formRule.id ? (
<div className={"access-control input-text ant-drawer-title"} onClick={() => toggleEditName(true)}>{formRule.id ? formRule.name : 'New Rule'}</div>
{!editName && formRule.id ? (
<div className={"access-control input-text ant-drawer-title"}
onClick={() => toggleEditName(true)}>{formRule.id ? formRule.name : 'New Rule'}</div>
) : (
<Form.Item
name="name"
label="Name"
rules={[{required: true, message: 'Please add a name for this access rule', whitespace: true}]}
rules={[{
required: true,
message: 'Please add a name for this access rule',
whitespace: true
}]}
>
<Input placeholder="Add rule name..." ref={inputNameRef} onPressEnter={() => toggleEditName(false)} onBlur={() => toggleEditName(false)} autoComplete="off"/>
<Input placeholder="Add rule name..." ref={inputNameRef}
onPressEnter={() => toggleEditName(false)}
onBlur={() => toggleEditName(false)} autoComplete="off"/>
</Form.Item>
)}
{ !editDescription ? (
<div className={"access-control input-text ant-drawer-subtitle"} onClick={() => toggleEditDescription(true)}>{formRule.description && formRule.description.trim() !== "" ? formRule.description : 'Add description...'}</div>
{!editDescription ? (
<div className={"access-control input-text ant-drawer-subtitle"}
onClick={() => toggleEditDescription(true)}>
{formRule.description && formRule.description.trim() !== "" ? formRule.description : 'Add description...'}
</div>
) : (
<Form.Item
name="description"
label="Description"
style={{marginTop: 24}}
>
<Input placeholder="Add description..." ref={inputDescriptionRef} onPressEnter={() => toggleEditDescription(false)} onBlur={() => toggleEditDescription(false)} autoComplete="off"/>
<Input placeholder="Add description..." ref={inputDescriptionRef}
onPressEnter={() => toggleEditDescription(false)}
onBlur={() => toggleEditDescription(false)}
autoComplete="off"/>
</Form.Item>
)}
</Col>
@@ -322,10 +348,10 @@ const AccessControlNew = () => {
<Form.Item
name="tagSourceGroups"
label="Source groups"
rules={[{ validator: selectValidator }]}
rules={[{validator: selectValidator}]}
>
<Select mode="tags"
style={{ width: '100%' }}
style={{width: '100%'}}
placeholder="Tags Mode"
tagRender={tagRender}
onChange={handleChangeSource}
@@ -343,10 +369,10 @@ const AccessControlNew = () => {
<Form.Item
name="tagDestinationGroups"
label="Destination groups"
rules={[{ validator: selectValidator }]}
rules={[{validator: selectValidator}]}
>
<Select
mode="tags" style={{ width: '100%' }}
mode="tags" style={{width: '100%'}}
placeholder="Tags Mode"
tagRender={tagRender}
onChange={handleChangeDestination}
@@ -367,10 +393,14 @@ const AccessControlNew = () => {
</Col>
<Col flex="auto">
<Paragraph>
At the moment access rules are bi-directional by default, this means both source and destination can talk to each-other in both directions. However destination peers will not be able to communicate with each other, nor will the source peers.
At the moment access rules are bi-directional by default, this means both
source and destination can talk to each-other in both directions. However
destination peers will not be able to communicate with each other, nor will
the source peers.
</Paragraph>
<Paragraph>
If you want to enable all peers of the same group to talk to each other - you can add that group both as a receiver and as a destination.
If you want to enable all peers of the same group to talk to each other -
you can add that group both as a receiver and as a destination.
</Paragraph>
</Col>
</Row>
@@ -378,8 +408,7 @@ const AccessControlNew = () => {
<Col span={24}>
<Divider></Divider>
<Button icon={<QuestionCircleFilled/>} type="link" target="_blank"
href="https://docs.netbird.io/docs/overview/acls" style={{color: 'rgb(07, 114, 128)'}}>Learn
more about access controls</Button>
href="https://docs.netbird.io/docs/overview/acls">Learn more about access controls</Button>
</Col>
</Row>
</Form>

View File

@@ -1,20 +1,28 @@
import { useState } from "react";
import {useEffect, useState} from "react";
import {Button, Col, Row, Space, Typography} from "antd";
import { CloseOutlined } from '@ant-design/icons';
import {Md5} from "ts-md5";
const { Text } = Typography
const Banner = () => {
const [show, setShow] = useState(true);
const [show, setShow] = useState(false);
const banner_md5_key = 'banner_md5'
const banner_closed_key = 'banner_closed'
const dismiss = () => {
setShow(false);
localStorage.setItem(banner_closed_key,'true');
};
const announcement = "New Release! Manage DNS with NetBird."
const announcement_md5 = Md5.hashStr(announcement)
const linkLearnMore = () => {
return (
<a
href="https://netbird.io/docs/how-to-guides/network-routes"
href="https://netbird.io/docs/how-to-guides/nameservers"
className="font-bold underline"
target="_blank"
rel="noreferrer"
@@ -22,12 +30,24 @@ const Banner = () => {
)
}
useEffect(()=>{
let store_banner_md5 = localStorage.getItem(banner_md5_key);
let stored_banner_closed = localStorage.getItem(banner_closed_key);
if((!stored_banner_closed || stored_banner_closed !== 'true') ||
(!store_banner_md5 || store_banner_md5 !== announcement_md5)) {
//setShow(true);
localStorage.setItem(banner_md5_key,announcement_md5);
localStorage.setItem(banner_closed_key,'false');
}
},[])
return show ? (
<div className="relative bg-indigo-600 white" color="white" style={{position: "relative", padding: "0.3rem"}} >
<Row>
<Col xs={24} sm={0} lg={0}>
<Text className="ant-col-md-0" style={{color: "#ffffff"}}>
New Release! Access private networks with the Network Routes feature.
{announcement}
</Text>
</Col>
<Col xs={24} sm={0} lg={0}>
@@ -38,7 +58,7 @@ const Banner = () => {
<Col xs={0} sm={24}>
<Space align="center" style={{display: "flex", justifyContent: "center"}}>
<Text style={{color: "#ffffff"}}>
New Release! Access private networks with the Network Routes feature.
{announcement}
</Text>
<span>
{linkLearnMore()}

View File

@@ -1,24 +1,36 @@
import {copyToClipboard} from "../utils/common";
import {Button, message} from "antd";
import {StepCommand} from "./addpeer/types";
import React from "react";
import {Button, message, Typography} from "antd";
import React, {ReactNode} from "react";
const {Text} = Typography;
type Props = {
keyMessage: string;
text: string;
toCopy: string;
body: ReactNode;
messageText: string;
styleNotification?: any;
style?: any;
className?:any;
className?: any;
};
const ButtonCopyMessage:React.FC<Props> = ({ keyMessage, text, messageText, styleNotification, style, className}) => {
const ButtonCopyMessage: React.FC<Props> = ({
keyMessage,
toCopy,
body,
messageText,
styleNotification,
style,
className
}) => {
const copyTextMessage = () => {
copyToClipboard(text)
message.success({ content: `${messageText}`, key: keyMessage, duration: 1, style: (styleNotification || {}) });
copyToClipboard(toCopy)
message.success({content: `${messageText}`, key: keyMessage, duration: 1, style: (styleNotification || {})});
}
return (
<Button type="text" onClick={copyTextMessage} style={style || {}} className={className}>{text}</Button>
<Button type="text" onClick={copyTextMessage} style={style || {}} className={className}>
{body}
</Button>
)
}

View File

@@ -1,17 +1,32 @@
import React from "react";
import loading from "../assets/bars.svg";
import {Space} from "antd";
import {OidcSecure} from "@axa-fr/react-oidc";
type Props = {
type LoadingProps = {
padding?: string;
width?: string;
height?: string;
width?: number;
height?: number;
};
const Loading:React.FC<Props> = ({padding, width, height}) => (
<Space direction="vertical" align="center" style={{display: 'flex', padding: `${padding || `.25em`}`}}>
<img src={loading} alt="Loading" style={{width: `${width || '25px'}`, height: `${height || '25px'}`}}/>
const Loading: React.FC<LoadingProps> = ({padding, width, height}) => (
<Space direction="vertical" align="center" style={{
marginTop: `-${height ? (height / 2) + "px" : '-25px'}`,
marginLeft: `-${width ? (width / 2) + "px" : '-25px'}`,
position: "absolute",
top: "50%",
left: "50%",
display: 'flex'
}}>
<img src={loading} alt="Loading"
style={{width: `${width ? width + "px" : '25px'}`, height: `${height ? height + "px" : '25px'}`}}/>
</Space>
);
export default Loading;
// Wrapper of Loading to handle cases when it is shown within the authenticated layout and has to trigger authentication when token expires.
export const SecureLoading = (props: LoadingProps) => (
<OidcSecure>
<Loading {...props} />
</OidcSecure>
);

View File

@@ -1,35 +1,57 @@
import {OidcUserStatus, useOidc, useOidcUser} from "@axa-fr/react-oidc";
import {Button, Result} from "antd";
import {useOidc, useOidcUser} from "@axa-fr/react-oidc";
import {Anchor, Button, Col, Result, Row, Space} from "antd";
import React from "react";
import {getConfig} from "../config";
import {ResultStatusType} from "antd/lib/result";
const {Link} = Anchor;
function LoginError() {
const { logout } = useOidc();
const {logout} = useOidc();
const config = getConfig();
const { oidcUserLoadingState } = useOidcUser();
const {oidcUserLoadingState} = useOidcUser();
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
if (urlParams.get("error") === "access_denied") {
let title = urlParams.get("error_description")
let status: ResultStatusType = "warning"
// this comes from the auth0 rule that links accounts
if (title === "account linked successfully") {
status = "success"
title = "Your account has been linked successfully. Please log in again to complete the setup."
}
return <Result
status="warning"
title={urlParams.get("error_description")}
status={status}
title={title}
extra={<>
<a href={window.location.origin}>
<Button type="primary">
Try again
</Button>
</a>
<Button type="primary" onClick={function () {
logout("",{client_id:config.clientId})
<Space style={{
display: "flex-inline",
flexDirection: "column",
justifyContent: "space-around",
alignContent: "center"
}}>
Log out
<h4>Already verified your email address?</h4>
<a href={window.location.origin}>
<Button type="primary">
Continue
</Button>
</a>
<Button type="link" onClick={function () {
logout("", {client_id: config.clientId})
}}>
Trouble logging in? Try again.
</Button>
</Space>
</>
}
/>
}
return <div>{"Login Error: User state: "+oidcUserLoadingState}</div>
return <div>{"Login Error: User state: " + oidcUserLoadingState}</div>
}
export default LoginError;

View File

@@ -0,0 +1,662 @@
import React, {useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {actions as nsGroupActions} from '../store/nameservers';
import {
Button,
Col,
Divider,
Drawer,
Form,
FormListFieldData,
Input,
InputNumber,
message,
Radio,
Row,
Select,
Space,
Tooltip,
Typography
} from "antd";
import {
CloseOutlined,
FlagFilled,
MinusCircleOutlined,
PlusOutlined,
QuestionCircleFilled,
QuestionCircleOutlined
} from "@ant-design/icons";
import {Header} from "antd/es/layout/layout";
import {RuleObject} from "antd/lib/form";
import cidrRegex from 'cidr-regex';
import {NameServer, NameServerGroup, NameServerGroupToSave} from "../store/nameservers/types";
import {useGetGroupTagHelpers} from "../utils/groups"
import {useGetTokenSilently} from "../utils/token";
const {Paragraph} = Typography;
interface formNSGroup extends NameServerGroup {
}
const NameServerGroupUpdate = () => {
const {
tagRender,
handleChangeTags,
dropDownRender,
optionRender,
tagGroups,
getExistingAndToCreateGroupsLists,
getGroupNamesFromIDs,
selectValidator
} = useGetGroupTagHelpers()
const dispatch = useDispatch()
const {getTokenSilently} = useGetTokenSilently()
const {Option} = Select;
const nsGroup = useSelector((state: RootState) => state.nameserverGroup.nameserverGroup)
const setupNewNameServerGroupVisible = useSelector((state: RootState) => state.nameserverGroup.setupNewNameServerGroupVisible)
const savedNSGroup = useSelector((state: RootState) => state.nameserverGroup.savedNameServerGroup)
const nsGroupData = useSelector((state: RootState) => state.nameserverGroup.data);
const [formNSGroup, setFormNSGroup] = useState({} as formNSGroup)
const [form] = Form.useForm()
const [editName, setEditName] = useState(false)
const [isPrimary, setIsPrimary] = useState(false)
const [editDescription, setEditDescription] = useState(false)
const inputNameRef = useRef<any>(null)
const inputDescriptionRef = useRef<any>(null)
const [selectCustom, setSelectCustom] = useState(false)
const optionsDisabledEnabled = [{label: 'Enabled', value: true}, {label: 'Disabled', value: false}]
const optionsPrimary = [{label: 'Yes', value: true}, {label: 'No', value: false}]
useEffect(() => {
if (editName) inputNameRef.current!.focus({
cursor: 'end',
});
}, [editName]);
useEffect(() => {
if (editDescription) inputDescriptionRef.current!.focus({
cursor: 'end',
});
}, [editDescription]);
useEffect(() => {
if (!nsGroup) return
let newFormGroup = {
...nsGroup,
groups: getGroupNamesFromIDs(nsGroup.groups),
} as formNSGroup
setFormNSGroup(newFormGroup)
form.setFieldsValue(newFormGroup)
if (nsGroup.id) {
setSelectCustom(true)
}
if (nsGroup.primary !== undefined) {
setIsPrimary(nsGroup.primary)
}
}, [nsGroup])
const onCancel = () => {
dispatch(nsGroupActions.setSetupNewNameServerGroupVisible(false));
dispatch(nsGroupActions.setNameServerGroup(
{
id: '',
name: '',
description: '',
primary: false,
domains: [],
nameservers: [] as NameServer[],
groups: [],
enabled: false,
} as NameServerGroup
))
setEditName(false)
setSelectCustom(false)
setIsPrimary(false)
}
const onChange = (changedValues: any) => {
if (changedValues.primary !== undefined) {
setIsPrimary(changedValues.primary)
}
setFormNSGroup({...formNSGroup, ...changedValues})
}
let googleChoice = 'Google DNS'
let cloudflareChoice = 'Cloudflare DNS'
let quad9Choice = 'Quad9 DNS'
let customChoice = 'Add custom nameserver'
let defaultDNSOptions: NameServerGroup[] = [
{
name: googleChoice,
description: 'Google DNS servers',
domains: [],
primary: true,
nameservers: [
{
ip: "8.8.8.8",
ns_type: "udp",
port: 53,
},
{
ip: "8.8.4.4",
ns_type: "udp",
port: 53,
},
],
groups: [],
enabled: true,
},
{
name: cloudflareChoice,
description: 'Cloudflare DNS servers',
domains: [],
primary: true,
nameservers: [
{
ip: "1.1.1.1",
ns_type: "udp",
port: 53,
},
{
ip: "1.0.0.1",
ns_type: "udp",
port: 53,
},
],
groups: [],
enabled: true,
},
{
name: quad9Choice,
description: 'Quad9 DNS servers',
domains: [],
primary: true,
nameservers: [
{
ip: "9.9.9.9",
ns_type: "udp",
port: 53,
},
{
ip: "149.112.112.112",
ns_type: "udp",
port: 53,
},
],
groups: [],
enabled: true,
},
]
const handleSelectChange = (value: string) => {
console.log(`selected ${value}`);
let nsGroupLocal = {} as NameServerGroup
if (value === customChoice) {
nsGroupLocal = nsGroup
} else {
defaultDNSOptions.forEach((nsg) => {
if (value === nsg.name) {
nsGroupLocal = nsg
}
})
}
let newFormGroup = {
...nsGroupLocal,
groups: getGroupNamesFromIDs(nsGroupLocal.groups),
} as formNSGroup
setFormNSGroup(newFormGroup)
form.setFieldsValue(newFormGroup)
setSelectCustom(true)
};
const handleFormSubmit = () => {
form.validateFields()
.then((values) => {
const nsGroupToSave = createNSGroupToSave(values as NameServerGroup)
dispatch(nsGroupActions.saveNameServerGroup.request({
getAccessTokenSilently: getTokenSilently,
payload: nsGroupToSave
}))
})
.then(() => onCancel())
.catch((errorInfo) => {
let msg = "please check the fields and try again"
if (errorInfo.errorFields) {
msg = errorInfo.errorFields[0].errors[0]
}
message.error({
content: msg,
duration: 1,
});
});
}
const createNSGroupToSave = (values: NameServerGroup): NameServerGroupToSave => {
let [existingGroups, newGroups] = getExistingAndToCreateGroupsLists(values.groups)
return {
id: formNSGroup.id || null,
name: values.name ? values.name : formNSGroup.name,
description: values.description ? values.description : formNSGroup.description,
primary: values.primary,
domains: values.primary ? [] : values.domains,
nameservers: values.nameservers,
groups: existingGroups,
groupsToCreate: newGroups,
enabled: values.enabled,
} as NameServerGroupToSave
}
const toggleEditName = (status: boolean) => {
setEditName(status)
}
const toggleEditDescription = (status: boolean) => {
setEditDescription(status)
}
const domainRegex = /(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/;
const domainValidator = (_: RuleObject, domain: string) => {
if (domainRegex.test(domain)) {
return Promise.resolve()
}
setIsPrimary(false)
return Promise.reject(new Error("Please enter a valid domain, e.g. example.com or intra.example.com"))
}
const nameValidator = (_: RuleObject, value: string) => {
const found = nsGroupData.find(u => u.name == value && u.id !== formNSGroup.id)
if (found) {
return Promise.reject(new Error("Please enter a unique name for your nameserver configuration"))
}
return Promise.resolve()
}
const ipValidator = (_: RuleObject, value: string) => {
if (!cidrRegex().test(value + "/32")) {
return Promise.reject(new Error("Please enter a valid IP, e.g. 192.168.1.1 or 8.8.8.8"))
}
return Promise.resolve()
}
// @ts-ignore
const formListValidator = (_: RuleObject, names) => {
if (names.length >= 3) {
return Promise.reject(new Error("Exceeded maximum number of Nameservers. (Max is 2)"));
}
if (names.length < 1) {
return Promise.reject(new Error("You should add at least 1 Nameserver"));
}
return Promise.resolve()
}
const primaryValidator = (_: RuleObject, primary: boolean) => {
if (!primary && form.getFieldValue("domains").length === 0) {
return Promise.reject(new Error("You should select between Resolve all domains or add one Match domain"));
}
if (primary && form.getFieldValue("domains").length > 0) {
return Promise.reject(new Error("You should remove all match domains before setting this to yes"));
}
return Promise.resolve()
}
// @ts-ignore
const renderNSList = (fields: FormListFieldData[], {add, remove}, {errors}) => (
<>
<Row>Nameservers</Row>
{!!fields.length && (
<Row align='middle'>
<Col span={6} style={{textAlign: 'center'}}>
<Typography.Text>Protocol</Typography.Text>
</Col>
<Col span={10} style={{textAlign: 'center'}}>
<Typography.Text>Nameserver IP</Typography.Text>
</Col>
<Col span={4} style={{textAlign: 'center'}}>
<Typography.Text>Port</Typography.Text>
</Col>
<Col span={2}/>
</Row>
)}
{fields.map((field, index) => {
return (
<Row key={index}>
<Col span={6} style={{textAlign: 'center'}}>
<Form.Item style={{margin: '3px'}}
name={[field.name, 'ns_type']}
rules={[{required: true, message: 'Missing first protocol'}]}
initialValue={"udp"}
>
<Select disabled style={{width: '100%'}}>
<Option value="udp">UDP</Option>
</Select>
</Form.Item>
</Col>
<Col span={10} style={{margin: '1px'}}>
<Form.Item style={{margin: '1px'}}
name={[field.name, 'ip']}
rules={[{validator: ipValidator}]}
>
<Input placeholder="e.g. X.X.X.X" style={{width: '100%'}}
autoComplete="off"/>
</Form.Item>
</Col>
<Col span={4} style={{textAlign: 'center'}}>
<Form.Item style={{margin: '1px'}}
name={[field.name, 'port']}
rules={[{required: true, message: 'Missing port'}]}
initialValue={53}
>
<InputNumber placeholder="Port" style={{width: '100%'}}/>
</Form.Item>
</Col>
<Col span={2} style={{textAlign: 'center'}}>
<MinusCircleOutlined onClick={() => remove(field.name)}/>
</Col>
</Row>
)
})}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined/>}>
Add nameserver
</Button>
</Form.Item>
<Form.ErrorList errors={errors}/>
</>
)
// @ts-ignore
const renderDomains = (fields: FormListFieldData[], {add, remove}, {errors}) => (
<>
<Row>
<Space>
<Col>
Match domains
</Col>
<Col>
<Tooltip title="Only queries to domains specified here will be resolved by these nameservers."
className={"ant-form-item-tooltip"}>
<QuestionCircleOutlined style={{color: "rgba(0, 0, 0, 0.45)", cursor: "help"}}/>
</Tooltip>
</Col>
</Space>
</Row>
{fields.map((field, index) => {
return (
<Row key={index}>
<Col span={20} style={{margin: '1px'}}>
<Form.Item hidden={isPrimary} style={{margin: '1px'}}
{...field}
rules={[{validator: domainValidator}]}
>
<Input placeholder="e.g. example.com" style={{width: '100%'}}
autoComplete="off"/>
</Form.Item>
</Col>
<Col span={2} style={{textAlign: 'center'}}>
<MinusCircleOutlined hidden={isPrimary} className="dynamic-delete-button"
onClick={() => remove(field.name)}/>
</Col>
</Row>
)
})}
<Form.Item>
<Button type="dashed" disabled={isPrimary} onClick={() => add()} block icon={<PlusOutlined/>}>
Add domain
</Button>
</Form.Item>
<Form.ErrorList errors={errors}/>
</>
)
return (
<>
{nsGroup &&
<Drawer
headerStyle={{display: "none"}}
forceRender={true}
open={setupNewNameServerGroupVisible}
bodyStyle={{paddingBottom: 80}}
onClose={onCancel}
autoFocus={true}
footer={
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button onClick={onCancel} disabled={savedNSGroup.loading}>Cancel</Button>
<Button type="primary" onClick={handleFormSubmit} disabled={savedNSGroup.loading}
>{`${formNSGroup.id ? 'Save' : 'Create'}`}</Button>
</Space>
}
>
{selectCustom ?
(<Form layout="vertical" requiredMark={false} form={form}
onValuesChange={onChange}
>
<Row gutter={16}>
<Col span={24}>
<Header style={{margin: "-32px -24px 20px -24px", padding: "24px 24px 0 24px"}}>
<Row align="top">
<Col flex="none" style={{display: "flex"}}>
{!editName && !editDescription && formNSGroup.id &&
<button type="button" aria-label="Close"
className="ant-drawer-close"
style={{paddingTop: 3}}
onClick={onCancel}>
<span role="img" aria-label="close"
className="anticon anticon-close">
<CloseOutlined size={16}/>
</span>
</button>
}
</Col>
<Col flex="auto">
{!editName && formNSGroup.id ? (
<div className={"access-control input-text ant-drawer-title"}
onClick={() => toggleEditName(true)}>{formNSGroup.id ? formNSGroup.name : 'New nameserver group'}</div>
) : (
<Form.Item
name="name"
label="Name"
tooltip="Add a nameserver group name"
rules={[
{
required: true,
message: 'Please add an identifier for this nameserver group',
whitespace: true
},
{
validator: nameValidator
}
]}
>
<Input placeholder="e.g. Public DNS" ref={inputNameRef}
onPressEnter={() => toggleEditName(false)}
onBlur={() => toggleEditName(false)} autoComplete="off"
maxLength={40}/>
</Form.Item>
)}
{!editDescription ? (
<div className={"access-control input-text ant-drawer-subtitle"}
onClick={() => toggleEditDescription(true)}>
{formNSGroup.description && formNSGroup.description.trim() !== "" ? formNSGroup.description : 'Add description...'}
</div>
) : (
<Form.Item
name="description"
label="Description"
style={{marginTop: 24}}
>
<Input placeholder="Add description..."
ref={inputDescriptionRef}
onPressEnter={() => toggleEditDescription(false)}
onBlur={() => toggleEditDescription(false)}
autoComplete="off"/>
</Form.Item>
)}
</Col>
</Row>
<Row align="top">
<Col flex="auto">
</Col>
</Row>
</Header>
</Col>
<Col span={24}>
</Col>
<Col span={24}>
<Form.Item
name="enabled"
label="Status"
>
<Radio.Group
options={optionsDisabledEnabled}
optionType="button"
buttonStyle="solid"
/>
</Form.Item>
</Col>
<Col span={24} flex="auto">
<Form.List
name="nameservers"
rules={[{validator: formListValidator}]}
>
{renderNSList}
</Form.List>
</Col>
<Col span={24}>
<Form.Item
name="primary"
label="Resolve all domains"
rules={[{validator: primaryValidator}]}
dependencies={['domains']} // trigger primaryValidation if domains is updated
tooltip="Defines if the nameservers are resolvers for all domains"
>
<Radio.Group
options={optionsPrimary}
optionType="button"
buttonStyle="solid"
/>
</Form.Item>
</Col>
<Col span={24} flex="auto">
<Form.List
name="domains"
>
{renderDomains}
</Form.List>
</Col>
<Col span={24}>
<Form.Item
name="groups"
label="Distribution groups"
tooltip="Distribution groups define to which group of peers these settings will be distributed to"
rules={[{validator: selectValidator}]}
>
<Select mode="tags"
style={{width: '100%'}}
placeholder="Associate groups with the NS group"
tagRender={tagRender}
onChange={handleChangeTags}
dropdownRender={dropDownRender}
>
{
tagGroups.map(m =>
<Option key={m}>{optionRender(m)}</Option>
)
}
</Select>
</Form.Item>
</Col>
<Col span={24}>
<Row wrap={false} gutter={12}>
<Col flex="none">
<FlagFilled/>
</Col>
<Col flex="auto">
<Paragraph>
Nameservers let you define resolvers for your DNS queries.
Because not all operating systems support match-only domain resolution,
you should define at least one set of nameservers to resolve all domains
per distribution group.
</Paragraph>
</Col>
</Row>
</Col>
<Col span={24}>
<Divider></Divider>
<Button icon={<QuestionCircleFilled/>} type="link" target="_blank"
href="https://netbird.io/docs/how-to-guides/nameservers">Learn more about nameservers</Button>
</Col>
</Row>
</Form>) :
(
<Space direction={"vertical"} style={{width: '100%'}}>
<Row align='middle'>
<Col span={24} style={{textAlign: 'left'}}>
<span className="ant-form-item">Select a predefined nameserver</span>
</Col>
</Row>
<Row align='middle'>
<Col span={24} style={{textAlign: 'center'}}>
<Select
style={{width: '100%'}}
onChange={handleSelectChange}
options={[
{
value: googleChoice,
label: googleChoice,
},
{
value: cloudflareChoice,
label: cloudflareChoice,
},
{
value: quad9Choice,
label: quad9Choice,
},
{
value: customChoice,
label: customChoice,
},
]}
/>
</Col>
</Row>
<Row align='middle'>
<Col span={24} style={{textAlign: 'left'}}>
<Col span={24} style={{textAlign: 'left'}}>
<span className="ant-form-item"><Typography.Link
onClick={() => handleSelectChange(customChoice)}>Or add custom</Typography.Link></span>
</Col>
</Col>
</Row>
</Space>
)
}
</Drawer>
}
</>
)
}
export default NameServerGroupUpdate

View File

@@ -1,49 +1,62 @@
import React, {useEffect, useState} from 'react';
import {Link} from 'react-router-dom';
import {Link, useLocation} from 'react-router-dom';
import logo from "../assets/logo.png";
import {useLocation} from 'react-router-dom';
import {Menu, Row, Col, Grid, Dropdown, Avatar, Button, Typography, Space} from 'antd'
import {Avatar, Button, Col, Dropdown, Grid, Menu, Row} from 'antd'
import {ItemType} from "antd/lib/menu/hooks/useItems";
import {AvatarSize} from "antd/es/avatar/SizeContext";
import { UserOutlined } from '@ant-design/icons';
import { useOidc,useOidcUser } from '@axa-fr/react-oidc';
import {UserOutlined} from '@ant-design/icons';
import {useOidc, useOidcIdToken, useOidcUser} from '@axa-fr/react-oidc';
import {getConfig} from "../config";
const { Text } = Typography
const { useBreakpoint } = Grid;
import {User} from "../store/user/types";
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {actions as userActions} from "../store/user";
import {useGetTokenSilently} from "../utils/token";
const {useBreakpoint} = Grid;
const Navbar = () => {
let location = useLocation();
const config = getConfig();
const {
isAuthenticated,
logout,
} = useOidc();
const { logout } = useOidc();
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const { oidcUser } = useOidcUser();
const {oidcUser} = useOidcUser();
const {idTokenPayload} = useOidcIdToken()
const user = oidcUser;
const [currentUser, setCurrentUser] = useState({} as User)
const screens = useBreakpoint();
const [hideMenuUser, setHideMenuUser] = useState(false)
const users = useSelector((state: RootState) => state.user.data)
const [isRefreshingUserState, setIsRefreshingUserState] = useState(false)
const items = [
{label: (<Link to="/peers">Peers</Link>), key: '/peers'},
{label: (<Link to="/setup-keys">Setup Keys</Link>), key: '/setup-keys'},
{label: (<Link to="/acls">Access Control</Link>), key: '/acls'},
{label: (<Link to="/routes">Network Routes</Link>), key: '/routes'},
{ label: (<Link to="/dns">DNS</Link>), key: '/dns' },
{label: (<Link to="/users">Users</Link>), key: '/users'},
{label: (<Link to="/activity">Activity</Link>), key: '/activity'},
{label: (<Link to="/settings">Settings</Link>), key: '/settings'}
] as ItemType[]
const userEmailKey = 'user-email'
const userLogoutKey = 'user-logout'
const userDividerKey = 'user-divider'
const [menuItems, setMenuItems] = useState([
{ label: (<Link to="/peers">Peers</Link>), key: '/peers' },
{ label: (<Link to="/add-peer">Add Peer</Link>), key: '/add-peer' },
{ label: (<Link to="/setup-keys">Setup Keys</Link>), key: '/setup-keys' },
{ label: (<Link to="/acls">Access Control</Link>), key: '/acls' },
{ label: (<Link to="/routes">Network Routes</Link>), key: '/routes' },
{ label: (<Link to="/users">Users</Link>), key: '/users' }
] as ItemType[])
const adminOnlyTabs = ["/setup-keys", "/acls", "/routes", "/dns", "/activity"]
const [menuItems, setMenuItems] = useState(items)
const logoutWithRedirect = () =>
logout("/",{client_id:config.clientId});
logout("/", {client_id: config.clientId});
useEffect(() => {
const fs = menuItems.filter(m => m?.key !== userEmailKey && m?.key !== userLogoutKey && m?.key !== userDividerKey)
const fs = items.filter(m => showTab(m?.key?.toString(), currentUser) && m?.key !== userEmailKey && m?.key !== userLogoutKey && m?.key !== userDividerKey)
if (screens.xs === true) {
setHideMenuUser(false)
fs.push({ type: 'divider', key: userDividerKey })
fs.push({type: 'divider', key: userDividerKey})
fs.push({
label: (
<Link to="#">{user?.name}</Link>
@@ -51,13 +64,51 @@ const Navbar = () => {
icon: createAvatar("small"),
key: userEmailKey
})
fs.push({ label: (<Button type="link" block onClick={logoutWithRedirect}>Logout</Button>), key: userLogoutKey })
fs.push({
label: (<Button type="link" block onClick={logoutWithRedirect}>Logout</Button>),
key: userLogoutKey
})
setMenuItems([...fs])
return
}
setMenuItems([...fs])
setHideMenuUser(true)
}, [screens])
}, [screens, currentUser])
useEffect(() => {
if (users.length === 0 && !isRefreshingUserState &&
window.location.pathname !== '/peers' &&
window.location.pathname !== '/users') {
setIsRefreshingUserState(true)
dispatch(userActions.getUsers.request({getAccessTokenSilently: getTokenSilently, payload: null}))
return
}
if (users.length === 0 && isRefreshingUserState) {
return
}
let runUser = oidcUser
if (!oidcUser) {
runUser = idTokenPayload
}
setIsRefreshingUserState(false)
if (runUser) {
const found = users.find(u => u.is_current ? u.is_current : runUser.sub ? u.id == runUser.sub : false)
if (found) {
setCurrentUser(found)
}
}
}, [users, oidcUser])
const showTab = (key: string | undefined, user: User | undefined) => {
if (!user) {
return false
}
if (user.role?.toLowerCase() === "admin") {
return true
}
return !adminOnlyTabs.find(t => t === key)
}
const menuUser = (
<Menu
@@ -67,16 +118,16 @@ const Navbar = () => {
key: '0',
},
{
label: (<Link to="/logout" onClick={logoutWithRedirect}>Logout</Link>),
label: (<Link to="/logout" onClick={logoutWithRedirect}>Logout</Link>),
key: '1',
}
]}
/>
);
const createAvatar = (size:AvatarSize) => {
const createAvatar = (size: AvatarSize) => {
return user?.picture ? (
<Avatar size={size} src={user?.picture} icon={<UserOutlined />} />
<Avatar size={size} src={user?.picture} icon={<UserOutlined/>}/>
) : (
<Avatar size={size}>{(user?.name || '').slice(0, 1).toUpperCase()}</Avatar>
)
@@ -96,7 +147,8 @@ const Navbar = () => {
</Col>
<Col flex="1 1 auto">
<div>
<Menu mode="horizontal" selectable={true} selectedKeys={[location.pathname]} defaultSelectedKeys={[location.pathname]} items={menuItems}/>
<Menu mode="horizontal" selectable={true} selectedKeys={[location.pathname]}
defaultSelectedKeys={[location.pathname]} items={menuItems}/>
</div>
</Col>
{hideMenuUser &&

View File

@@ -2,23 +2,29 @@ import React, {useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {actions as peerActions} from '../store/peer';
import {Button, Col, Divider, Drawer, Form, Input, Row, Select, Space, Tag, Typography} from "antd";
import {Button, Col, Collapse, Divider, Drawer, Form, Input, Radio, Row, Select, Space, Tag, Typography} from "antd";
import {Header} from "antd/es/layout/layout";
import type {CustomTagProps} from 'rc-select/lib/BaseSelect'
import {Peer, PeerGroupsToSave} from "../store/peer/types";
import {FormPeer, Peer, PeerGroupsToSave} from "../store/peer/types";
import {Group, GroupPeer} from "../store/group/types";
import {CloseOutlined, EditOutlined, FlagFilled} from "@ant-design/icons";
import {CloseOutlined, EditOutlined} from "@ant-design/icons";
import {RuleObject} from 'antd/lib/form';
import { useOidcAccessToken } from '@axa-fr/react-oidc';
const { Paragraph } = Typography;
const { Option } = Select;
import {useGetTokenSilently} from "../utils/token";
import {timeAgo} from "../utils/common";
const {Paragraph} = Typography;
const {Option} = Select;
const {Panel} = Collapse;
const punycode = require('punycode/')
const PeerUpdate = () => {
const { accessToken } = useOidcAccessToken()
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const groups = useSelector((state: RootState) => state.group.data)
const peer = useSelector((state: RootState) => state.peer.peer)
const [formPeer, setFormPeer] = useState({} as Peer)
const groups = useSelector((state: RootState) => state.group.data)
const users = useSelector((state: RootState) => state.user.data)
const peer: Peer = useSelector((state: RootState) => state.peer.peer)
const [formPeer, setFormPeer] = useState({} as FormPeer)
const updateGroupsVisible = useSelector((state: RootState) => state.peer.updateGroupsVisible)
const savedGroups = useSelector((state: RootState) => state.peer.savedGroups)
const updatedPeers = useSelector((state: RootState) => state.peer.updatedPeer)
@@ -28,6 +34,7 @@ const PeerUpdate = () => {
const [peerGroups, setPeerGroups] = useState([] as GroupPeer[])
const inputNameRef = useRef<any>(null)
const [editName, setEditName] = useState(false)
const [estimatedName, setEstimatedName] = useState("")
const [callingPeerAPI, setCallingPeerAPI] = useState(false)
const [callingGroupAPI, setCallingGroupAPI] = useState(false)
const [isSubmitRunning, setSubmitRunning] = useState(false)
@@ -46,21 +53,21 @@ const PeerUpdate = () => {
if (callingPeerAPI && updatedPeers.success) {
setCallingPeerAPI(false)
}
},[updatedPeers])
}, [updatedPeers])
// wait save groups to succeed
useEffect(() => {
if (callingGroupAPI && savedGroups.success) {
setCallingGroupAPI(false)
}
},[savedGroups])
}, [savedGroups])
// clean temp state and close
useEffect(() => {
if (isSubmitRunning && !callingGroupAPI && !callingPeerAPI) {
onCancel()
}
},[callingGroupAPI,callingPeerAPI])
}, [callingGroupAPI, callingPeerAPI])
useEffect(() => {
if (editName) inputNameRef.current!.focus({
@@ -74,18 +81,26 @@ const PeerUpdate = () => {
const gs_name = gs?.map(g => g.name) as string[]
setPeerGroups(gs)
setSelectedTagGroups(gs_name)
setFormPeer(peer)
form.setFieldsValue({
name: formPeer.name ? formPeer.name: peer.name,
groups: gs_name
})
const fPeer = {
...peer,
name: formPeer.name ? formPeer.name : peer.name,
groupsNames: gs_name,
userEmail: users?.find(u => u.id === peer.user_id)?.email,
last_seen: peer.connected ? "just now" : String(timeAgo(peer.last_seen)),
ui_version: peer.ui_version ? peer.ui_version.replace("netbird-desktop-ui/", "") : ""
} as FormPeer
setFormPeer(fPeer)
form.setFieldsValue(fPeer)
}, [peer])
useEffect(() => {
setTagGroups(groups?.map(g => g.name) || [])
}, [groups])
const toggleEditName = (status:boolean) => {
useEffect(() => {
}, [users])
const toggleEditName = (status: boolean) => {
setEditName(status)
}
@@ -103,7 +118,7 @@ const PeerUpdate = () => {
}, [selectedTagGroups])
const tagRender = (props: CustomTagProps) => {
const { label, value, closable, onClose } = props;
const {label, value, closable, onClose} = props;
const onPreventMouseDown = (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
@@ -120,7 +135,7 @@ const PeerUpdate = () => {
onMouseDown={onPreventMouseDown}
closable={tagClosable}
onClose={onClose}
style={{ marginRight: 3 }}
style={{marginRight: 3}}
>
<strong>{value}</strong>
</Tag>
@@ -130,12 +145,12 @@ const PeerUpdate = () => {
const optionRender = (label: string) => {
let peersCount = ''
const g = groups.find(_g => _g.name === label)
if (g) peersCount = ` - ${g.peers_count || 0} ${(!g.peers_count || parseInt(g.peers_count) !== 1) ? 'peers' : 'peer'} `
if (g) peersCount = ` - ${g.peers_count || 0} ${(!g.peers_count || parseInt(g.peers_count) !== 1) ? 'peers' : 'peer'} `
return (
<>
<Tag
color="blue"
style={{ marginRight: 3 }}
style={{marginRight: 3}}
>
<strong>{label}</strong>
</Tag>
@@ -147,21 +162,23 @@ const PeerUpdate = () => {
const dropDownRender = (menu: React.ReactElement) => (
<>
{menu}
<Divider style={{ margin: '8px 0' }} />
<Divider style={{margin: '8px 0'}}/>
<Row style={{padding: '0 8px 4px'}}>
<Col flex="auto">
<span style={{color: "#9CA3AF"}}>Add new group by pressing "Enter"</span>
</Col>
<Col flex="none">
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.70455 7.19176V5.89915H10.3949C10.7727 5.89915 11.1174 5.80634 11.429 5.62074C11.7405 5.43513 11.9875 5.18655 12.1697 4.875C12.3554 4.56345 12.4482 4.21875 12.4482 3.84091C12.4482 3.46307 12.3554 3.12003 12.1697 2.81179C11.9841 2.50024 11.7356 2.25166 11.424 2.06605C11.1158 1.88044 10.7727 1.78764 10.3949 1.78764H9.83807V0.5H10.3949C11.0114 0.5 11.5715 0.650805 12.0753 0.952414C12.5791 1.25402 12.9818 1.65672 13.2834 2.16051C13.585 2.6643 13.7358 3.22443 13.7358 3.84091C13.7358 4.30161 13.648 4.73414 13.4723 5.13849C13.3 5.54285 13.0613 5.89915 12.7564 6.20739C12.4515 6.51562 12.0968 6.75758 11.6925 6.93324C11.2881 7.10559 10.8556 7.19176 10.3949 7.19176H1.70455ZM4.90128 11.0646L0.382102 6.54545L4.90128 2.02628L5.79119 2.91619L2.15696 6.54545L5.79119 10.1747L4.90128 11.0646Z" fill="#9CA3AF"/>
<path
d="M1.70455 7.19176V5.89915H10.3949C10.7727 5.89915 11.1174 5.80634 11.429 5.62074C11.7405 5.43513 11.9875 5.18655 12.1697 4.875C12.3554 4.56345 12.4482 4.21875 12.4482 3.84091C12.4482 3.46307 12.3554 3.12003 12.1697 2.81179C11.9841 2.50024 11.7356 2.25166 11.424 2.06605C11.1158 1.88044 10.7727 1.78764 10.3949 1.78764H9.83807V0.5H10.3949C11.0114 0.5 11.5715 0.650805 12.0753 0.952414C12.5791 1.25402 12.9818 1.65672 13.2834 2.16051C13.585 2.6643 13.7358 3.22443 13.7358 3.84091C13.7358 4.30161 13.648 4.73414 13.4723 5.13849C13.3 5.54285 13.0613 5.89915 12.7564 6.20739C12.4515 6.51562 12.0968 6.75758 11.6925 6.93324C11.2881 7.10559 10.8556 7.19176 10.3949 7.19176H1.70455ZM4.90128 11.0646L0.382102 6.54545L4.90128 2.02628L5.79119 2.91619L2.15696 6.54545L5.79119 10.1747L4.90128 11.0646Z"
fill="#9CA3AF"/>
</svg>
</Col>
</Row>
</>
)
const setUpdateGroupsVisible = (status:boolean) => {
const setUpdateGroupsVisible = (status: boolean) => {
dispatch(peerActions.setUpdateGroupsVisible(status));
}
@@ -170,27 +187,32 @@ const PeerUpdate = () => {
setUpdateGroupsVisible(false)
setEditName(false)
// setSaveBtnDisabled(true)
setFormPeer({} as Peer)
setFormPeer({} as FormPeer)
setCallingPeerAPI(false)
setCallingPeerAPI(false)
setSubmitRunning(false)
setEstimatedName("")
}
const noUpdateToGroups = ():Boolean => {
const noUpdateToGroups = (): Boolean => {
return !peerGroupsToSave.groupsToRemove.length && !peerGroupsToSave.groupsToAdd.length && !peerGroupsToSave.groupsNoId.length
}
const noUpdateToName = ():Boolean => {
const noUpdateToName = (): Boolean => {
return !formPeer.name || formPeer.name === peer.name
}
const onChange = (data:any) => {
const noUpdateToLoginExpiration = (): Boolean => {
return formPeer.login_expiration_enabled === peer.login_expiration_enabled
}
const onChange = (data: any) => {
setFormPeer({...formPeer, ...data})
}
const handleChangeTags = (value: string[]) => {
let validatedValues: string[] = []
value.forEach(function(v) {
value.forEach(function (v) {
if (v.trim().length) {
validatedValues.push(v)
}
@@ -198,11 +220,26 @@ const PeerUpdate = () => {
setSelectedTagGroups(validatedValues)
};
const createPeerToSave = ():Peer => {
const nameValidator = (_: RuleObject, value: string) => {
let punyName = punycode.toASCII(value.toLowerCase())
let domain = ""
if (formPeer.dns_label) {
let labelList = formPeer.dns_label.split(".")
if (labelList.length > 1) {
labelList.splice(0,1)
domain = "." + labelList.join(".")
}
}
setEstimatedName(punyName+domain)
return Promise.resolve()
}
const createPeerToSave = (): Peer => {
return {
id: formPeer.id,
ssh_enabled: formPeer.ssh_enabled,
name: formPeer.name
name: formPeer.name,
login_expiration_enabled: formPeer.login_expiration_enabled
} as Peer
}
@@ -210,14 +247,20 @@ const PeerUpdate = () => {
form.validateFields()
.then((values) => {
setSubmitRunning(true)
if (!noUpdateToName()) {
if (!noUpdateToName() || !noUpdateToLoginExpiration()) {
const peerUpdate = createPeerToSave()
setCallingPeerAPI(true)
dispatch(peerActions.updatePeer.request({getAccessTokenSilently:accessToken, payload: peerUpdate}))
dispatch(peerActions.updatePeer.request({
getAccessTokenSilently: getTokenSilently,
payload: peerUpdate
}))
}
if (peerGroupsToSave.groupsToRemove.length || peerGroupsToSave.groupsToAdd.length || peerGroupsToSave.groupsNoId.length) {
if (!noUpdateToGroups()) {
setCallingGroupAPI(true)
dispatch(peerActions.saveGroups.request({getAccessTokenSilently:accessToken, payload: peerGroupsToSave}))
dispatch(peerActions.saveGroups.request({
getAccessTokenSilently: getTokenSilently,
payload: peerGroupsToSave
}))
}
})
.catch((errorInfo) => {
@@ -233,7 +276,7 @@ const PeerUpdate = () => {
return Promise.reject(new Error("Please enter ate least one group"))
}
value.forEach(function(v: string) {
value.forEach(function (v: string) {
if (!v.trim().length) {
hasSpaceNamed.push(v)
}
@@ -259,25 +302,28 @@ const PeerUpdate = () => {
<Drawer
forceRender={true}
headerStyle={{display: "none"}}
visible={true}
open={true}
bodyStyle={{paddingBottom: 80}}
onClose={onCancel}
autoFocus={true}
footer={
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button onClick={onCancel} disabled={savedGroups.loading}>Cancel</Button>
<Button type="primary" disabled={(savedGroups.loading || updatedPeers.loading) || (noUpdateToGroups() && noUpdateToName())} onClick={handleFormSubmit}>Save</Button>
<Button type="primary"
disabled={(savedGroups.loading || updatedPeers.loading) || (noUpdateToGroups() && noUpdateToName() && noUpdateToLoginExpiration())}
onClick={handleFormSubmit}>Save</Button>
</Space>
}
>
<Form layout="vertical" hideRequiredMark form={form} onValuesChange={onChange}>
<Form layout="vertical" requiredMark={false} form={form} onValuesChange={onChange}>
<Row gutter={16}>
<Col span={24}>
<Header style={{margin: "-32px -24px 20px -24px", padding: "24px 24px 0 24px"}}>
<Row align="top">
<Col flex="none" style={{display: "flex"}}>
{!editName && peer.id &&
<button type="button" aria-label="Close" className="ant-drawer-close"
<button type="button" aria-label="Close" autoFocus={true}
className="ant-drawer-close"
style={{paddingTop: 3}}
onClick={onCancel}>
<span role="img" aria-label="close"
@@ -293,46 +339,135 @@ const PeerUpdate = () => {
onClick={() => toggleEditName(true)}>{formPeer.name ? formPeer.name : peer.name}
<EditOutlined/></div>
) : (
<Form.Item
name="name"
label="Name"
rules={[{
required: true,
message: 'Please add a new name for this peer',
whitespace: true
}]}
style={{display: 'flex'}}
>
<Input
placeholder={peer.name}
ref={inputNameRef}
onPressEnter={() => toggleEditName(false)}
onBlur={() => toggleEditName(false)}
// onChange={(e) => handleChangeName(e.)}
autoComplete="off"/>
</Form.Item>)}
<Row>
<Space direction={"vertical"} size="small">
<Form.Item
name="name"
label="Name"
style={{margin: '1px'}}
rules={[{
required: true,
message: 'Please add a new name for this peer',
whitespace: true
},{validator:nameValidator}]}
>
<Input
placeholder={peer.name}
ref={inputNameRef}
onPressEnter={() => toggleEditName(false)}
onBlur={() => toggleEditName(false)}
autoComplete="off"
max={59}/>
</Form.Item>
<Form.Item
label="New peer domain name preview"
tooltip="If the domain name already exists, we add an increment number suffix to it"
style={{margin: '1px'}}
>
<Paragraph>
<Tag>
{estimatedName}
</Tag>
</Paragraph>
</Form.Item>
</Space>
</Row>
)}
</Col>
</Row>
</Header>
</Col>
</Row>
<Row gutter={16}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="ip"
label={<>
<span style={{
marginRight: "5px",
}}>NetBird IP</span>
<Tag
color={formPeer.connected ? "green" : "red"}>{formPeer.connected ? "online" : "offline"}</Tag>
</>}
>
<Input
disabled={true}
value={formPeer.ip}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="last_seen"
label="Last seen"
>
<Input
disabled={true}
value={formPeer.last_seen}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={24}>
<Form.Item
name="groups"
label="Select groups to associate with this peer"
rules={[{ validator: selectValidator }]}
style={{display: 'flex'}}
name="dns_label"
label="Domain name"
>
<Input
disabled={true}
value={formPeer.userEmail}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
{formPeer.user_id && (
<Col span={24}>
<Form.Item
name="userEmail"
label="User"
>
<Input
disabled={true}
value={formPeer.userEmail}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>
)}
<Col span={24}>
<Form.Item
name="login_expiration_enabled"
label="Login expiration"
tooltip="When login expires, the user has to re-authenticate this peer. This only applies to peers added with the SSO login."
>
<Radio.Group
options={[{label: 'Enabled', value: true}, {label: 'Disabled', value: false}]}
optionType="button"
buttonStyle="solid"
disabled={!formPeer.user_id}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="groupsNames"
label="Select peer groups"
rules={[{validator: selectValidator}]}
>
<Select
mode="tags"
style={{ width: '100%' }}
style={{width: '100%'}}
placeholder="Select groups..."
tagRender={tagRender}
// onDropdownVisibleChange={evaluateSubmit}
// onSelect={evaluateSubmit}
dropdownRender={dropDownRender}
onChange={handleChangeTags}>
@@ -345,16 +480,62 @@ const PeerUpdate = () => {
</Form.Item>
</Col>
<Col span={24}>
<Row wrap={false} gutter={12}>
<Col flex="none">
<FlagFilled/>
</Col>
<Col flex="auto">
<Paragraph>
Every peer is part of the group All, thus you can't remove it.
</Paragraph>
</Col>
</Row>
<Collapse onChange={onChange} bordered={false} ghost={true}
style={{color: "#5a5c5a"}}>
<Panel key="0" header="System Info">
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="hostname"
label="Hostname"
>
<Input
disabled={true}
value={formPeer.hostname}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="os"
label="Operating system"
>
<Input
disabled={true}
value={formPeer.os}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="version"
label="Agent version"
>
<Input
disabled={true}
value={formPeer.os}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>
{formPeer.ui_version && (
<Col span={12}>
<Form.Item
name="ui_version"
label="UI version"
>
<Input
disabled={true}
value={formPeer.ui_version}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>)}
</Row>
</Panel>
</Collapse>
</Col>
</Row>
</Form>

View File

@@ -1,46 +1,63 @@
import React, {useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import { actions as routeActions } from '../store/route';
import {actions as routeActions} from '../store/route';
import {
Button,
Col,
Row,
Divider,
Drawer,
Form,
Input,
InputNumber,
Radio,
Row,
Select,
SelectProps,
Space,
Switch,
SelectProps,
Button, Drawer, Form, Divider, Select, Radio, Typography
Typography
} from "antd";
import {CloseOutlined, FlagFilled, QuestionCircleFilled} from "@ant-design/icons";
import {Route} from "../store/route/types";
import {Route, RouteToSave} from "../store/route/types";
import {Header} from "antd/es/layout/layout";
import {RuleObject} from "antd/lib/form";
import {useOidcAccessToken} from "@axa-fr/react-oidc";
import cidrRegex from 'cidr-regex';
import {
initPeerMaps,
masqueradeDisabledMSG,
peerToPeerIP,
initPeerMaps,
routePeerSeparator,
transformGroupedDataTable
} from '../utils/routes'
import {useGetTokenSilently} from "../utils/token";
import {useGetGroupTagHelpers} from "../utils/groups";
const { Paragraph } = Typography;
const {Paragraph} = Typography;
interface FormRoute extends Route {
}
const RouteUpdate = () => {
const {accessToken} = useOidcAccessToken()
const {
tagRender,
handleChangeTags,
dropDownRender,
optionRender,
tagGroups,
getExistingAndToCreateGroupsLists,
getGroupNamesFromIDs,
selectValidator
} = useGetGroupTagHelpers()
const {Option} = Select;
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const setupNewRouteVisible = useSelector((state: RootState) => state.route.setupNewRouteVisible)
const setupNewRouteHA = useSelector((state: RootState) => state.route.setupNewRouteHA)
const peers = useSelector((state: RootState) => state.peer.data)
const route = useSelector((state: RootState) => state.route.route)
const routes = useSelector((state: RootState) => state.route.data)
const peers = useSelector((state: RootState) => state.peer.data)
const route = useSelector((state: RootState) => state.route.route)
const routes = useSelector((state: RootState) => state.route.data)
const savedRoute = useSelector((state: RootState) => state.route.savedRoute)
// const [groupedDataTable, setGroupedDataTable] = useState([] as GroupedDataTable[]);
const [previousRouteKey, setPreviousRouteKey] = useState("")
const [editName, setEditName] = useState(false)
const [editDescription, setEditDescription] = useState(false)
@@ -56,13 +73,14 @@ const RouteUpdate = () => {
const [masqueradeMSG, setMasqueradeMSG] = useState(defaultMasqueradeMSG)
const defaultStatusMSG = "Status"
const [statusMSG, setStatusMSG] = useState(defaultStatusMSG)
const [peerNameToIP, peerIPToName] = initPeerMaps(peers);
const [peerNameToIP, peerIPToName, peerIPToID] = initPeerMaps(peers);
const [newRoute, setNewRoute] = useState(false)
const optionsDisabledEnabled = [{label: 'Enabled', value: true}, {label: 'Disabled', value: false}]
useEffect(() => {
if (setupNewRouteHA) {
setRoutingPeerMSG("Add additional routing peer")
if (!newRoute ) {
setRoutingPeerMSG(defaultRoutingPeerMSG)
setMasqueradeMSG("Update Masquerade")
setStatusMSG("Update Status")
} else {
@@ -71,7 +89,7 @@ const RouteUpdate = () => {
setStatusMSG(defaultStatusMSG)
setPreviousRouteKey("")
}
}, [setupNewRouteHA])
}, [newRoute])
useEffect(() => {
if (editName) inputNameRef.current!.focus({
@@ -90,33 +108,41 @@ const RouteUpdate = () => {
const fRoute = {
...route,
groups: getGroupNamesFromIDs(route.groups)
} as FormRoute
setFormRoute(fRoute)
setPreviousRouteKey(fRoute.network_id+fRoute.network)
setPreviousRouteKey(fRoute.network_id + fRoute.network)
if (!route.network_id) {
setNewRoute(true)
} else {
setNewRoute(false)
}
form.setFieldsValue(fRoute)
}, [route])
peers.forEach((p) => {
let os:string
let os: string
os = p.os
if (!os.toLowerCase().startsWith("darwin") && !os.toLowerCase().startsWith("windows")) {
options?.push({
label: peerToPeerIP(p.name,p.ip),
value: peerToPeerIP(p.name,p.ip),
label: peerToPeerIP(p.name, p.ip),
value: peerToPeerIP(p.name, p.ip),
disabled: false
})
}
})
const createRouteToSave = (inputRoute:FormRoute):Route => {
const createRouteToSave = (inputRoute: FormRoute): RouteToSave => {
let peerIDList = inputRoute.peer.split(routePeerSeparator)
let peerID:string
let peerID: string
if (peerIDList[1]) {
peerID = peerIDList[1]
peerID = peerIPToID[peerIDList[1]]
} else {
peerID = peerNameToIP[inputRoute.peer]
peerID = peerIPToID[peerNameToIP[inputRoute.peer]]
}
let [ existingGroups, groupsToCreate ] = getExistingAndToCreateGroupsLists(inputRoute.groups)
return {
id: inputRoute.id,
network: inputRoute.network,
@@ -125,8 +151,10 @@ const RouteUpdate = () => {
peer: peerID,
enabled: inputRoute.enabled,
masquerade: inputRoute.masquerade,
metric: inputRoute.metric
} as Route
metric: inputRoute.metric,
groups: existingGroups,
groupsToCreate: groupsToCreate,
} as RouteToSave
}
const handleFormSubmit = () => {
@@ -134,13 +162,16 @@ const RouteUpdate = () => {
.then(() => {
if (!setupNewRouteHA || formRoute.peer != '') {
const routeToSave = createRouteToSave(formRoute)
dispatch(routeActions.saveRoute.request({getAccessTokenSilently:accessToken, payload: routeToSave}))
dispatch(routeActions.saveRoute.request({
getAccessTokenSilently: getTokenSilently,
payload: routeToSave
}))
} else {
let groupedDataTable = transformGroupedDataTable(routes,peerIPToName)
let groupedDataTable = transformGroupedDataTable(routes, peers)
groupedDataTable.forEach((group) => {
if (group.key == previousRouteKey) {
group.groupedRoutes.forEach((route) => {
let updateRoute:FormRoute = {
let updateRoute: FormRoute = {
...formRoute,
id: route.id,
peer: route.peer,
@@ -148,7 +179,10 @@ const RouteUpdate = () => {
enabled: (formRoute.enabled != group.enabled) ? formRoute.enabled : route.enabled
}
const routeToSave = createRouteToSave(updateRoute)
dispatch(routeActions.saveRoute.request({getAccessTokenSilently:accessToken, payload: routeToSave}))
dispatch(routeActions.saveRoute.request({
getAccessTokenSilently: getTokenSilently,
payload: routeToSave
}))
})
}
})
@@ -160,11 +194,11 @@ const RouteUpdate = () => {
});
};
const setVisibleNewRoute = (status:boolean) => {
const setVisibleNewRoute = (status: boolean) => {
dispatch(routeActions.setSetupNewRouteVisible(status));
}
const setSetupNewRouteHA = (status:boolean) => {
const setSetupNewRouteHA = (status: boolean) => {
dispatch(routeActions.setSetupNewRouteHA(status));
}
@@ -183,23 +217,24 @@ const RouteUpdate = () => {
setVisibleNewRoute(false)
setSetupNewRouteHA(false)
setPreviousRouteKey("")
setNewRoute(false)
}
const onChange = (data:any) => {
const onChange = (data: any) => {
setFormRoute({...formRoute, ...data})
}
const dropDownRender = (menu: React.ReactElement) => (
const peerDropDownRender = (menu: React.ReactElement) => (
<>
{menu}
</>
)
const toggleEditName = (status:boolean) => {
const toggleEditName = (status: boolean) => {
setEditName(status);
}
const toggleEditDescription = (status:boolean) => {
const toggleEditDescription = (status: boolean) => {
setEditDescription(status);
}
@@ -215,61 +250,96 @@ const RouteUpdate = () => {
return Promise.resolve()
}
const peerValidator = (_: RuleObject, value: string) => {
if (value == "" && newRoute) {
return Promise.reject(new Error("Please select routing one peer"))
}
return Promise.resolve()
}
const selectPreValidator = (obj: RuleObject, value: string[]) => {
if (setupNewRouteHA && formRoute.peer == '') {
let [, newGroups ] = getExistingAndToCreateGroupsLists(value)
if (newGroups.length > 0) {
return Promise.reject(new Error("You can't add new Groups from the group update view, please remove:\"" + newGroups +"\""))
}
}
return selectValidator(obj, value)
}
return (
<>
{route &&
<Drawer
headerStyle={{display: "none"}}
forceRender={true}
visible={setupNewRouteVisible}
open={setupNewRouteVisible}
bodyStyle={{paddingBottom: 80}}
onClose={onCancel}
autoFocus={true}
footer={
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button onClick={onCancel} disabled={savedRoute.loading}>Cancel</Button>
<Button type="primary" disabled={savedRoute.loading} onClick={handleFormSubmit}>{`${formRoute.network_id ? 'Save' : 'Create'}`}</Button>
<Button type="primary" disabled={savedRoute.loading}
onClick={handleFormSubmit}>{`${newRoute ? 'Create' : 'Save'}`}</Button>
</Space>
}
>
<Form layout="vertical" hideRequiredMark form={form} onValuesChange={onChange}>
<Form layout="vertical" form={form} requiredMark={false} onValuesChange={onChange}>
<Row gutter={16}>
<Col span={24}>
<Header style={{margin: "-32px -24px 20px -24px", padding: "24px 24px 0 24px"}}>
<Row align="top">
<Col flex="none" style={{display: "flex"}}>
{!editName && !editDescription && formRoute.id &&
{!editName && !editDescription && formRoute.id &&
<button type="button" aria-label="Close" className="ant-drawer-close"
style={{paddingTop: 3}}
onClick={onCancel}>
<span role="img" aria-label="close" className="anticon anticon-close">
<span role="img" aria-label="close"
className="anticon anticon-close">
<CloseOutlined size={16}/>
</span>
</button>
}
</Col>
<Col flex="auto">
{ !editName && formRoute.id ? (
<div className={"access-control input-text ant-drawer-title"} onClick={() => toggleEditName(true)}>{formRoute.id ? formRoute.network_id : 'New Route'}</div>
{!editName && formRoute.id ? (
<div className={"access-control input-text ant-drawer-title"}
onClick={() => toggleEditName(true)}>{formRoute.id ? formRoute.network_id : 'New Route'}</div>
) : (
<Form.Item
name="network_id"
label="Network Identifier"
tooltip="You can enable high-availability by assigning the same network identifier and network CIDR to multiple routes"
rules={[{required: true, message: 'Please add an identifier for this access route', whitespace: true}]}
rules={[{
required: true,
message: 'Please add an identifier for this access route',
whitespace: true
}]}
>
<Input placeholder="e.g. aws-eu-central-1-vpc" ref={inputNameRef} disabled={!setupNewRouteHA} onPressEnter={() => toggleEditName(false)} onBlur={() => toggleEditName(false)} autoComplete="off" maxLength={40}/>
<Input placeholder="e.g. aws-eu-central-1-vpc" ref={inputNameRef}
disabled={!setupNewRouteHA && !newRoute}
onPressEnter={() => toggleEditName(false)}
onBlur={() => toggleEditName(false)} autoComplete="off"
maxLength={40}/>
</Form.Item>
)}
{ !editDescription ? (
<div className={"access-control input-text ant-drawer-subtitle"} onClick={() => toggleEditDescription(true)}>{formRoute.description && formRoute.description.trim() !== "" ? formRoute.description : 'Add description...'}</div>
{!editDescription ? (
<div className={"access-control input-text ant-drawer-subtitle"}
onClick={() => toggleEditDescription(true)}>{formRoute.description && formRoute.description.trim() !== "" ? formRoute.description : 'Add description...'}</div>
) : (
<Form.Item
name="description"
label="Description"
style={{marginTop: 24}}
>
<Input placeholder="Add description..." ref={inputDescriptionRef} disabled={!setupNewRouteHA} onPressEnter={() => toggleEditDescription(false)} onBlur={() => toggleEditDescription(false)} autoComplete="off" maxLength={200}/>
<Input placeholder="Add description..." ref={inputDescriptionRef}
disabled={!setupNewRouteHA && !newRoute}
onPressEnter={() => toggleEditDescription(false)}
onBlur={() => toggleEditDescription(false)}
autoComplete="off" maxLength={200}/>
</Form.Item>
)}
@@ -293,7 +363,8 @@ const RouteUpdate = () => {
tooltip="Use CIDR notation. e.g. 192.168.10.0/24 or 172.16.0.0/16"
rules={[{validator: networkRangeValidator}]}
>
<Input placeholder="e.g. 172.16.0.0/16" disabled={!setupNewRouteHA} autoComplete="off" minLength={9} maxLength={43}/>
<Input placeholder="e.g. 172.16.0.0/16" disabled={!setupNewRouteHA && !newRoute}
autoComplete="off" minLength={9} maxLength={43}/>
</Form.Item>
</Col>
<Col span={24}>
@@ -314,14 +385,15 @@ const RouteUpdate = () => {
name="peer"
label={routingPeerMSG}
tooltip="Assign a peer as a routing peer for the Network CIDR"
rules={[{validator:peerValidator}]}
>
<Select
showSearch
style={{ width: '100%' }}
placeholder="Select Peer"
dropdownRender={dropDownRender}
options={options}
allowClear={true}
showSearch
style={{width: '100%'}}
placeholder="Select Peer"
dropdownRender={peerDropDownRender}
options={options}
allowClear={true}
/>
</Form.Item>
</Col>
@@ -331,7 +403,7 @@ const RouteUpdate = () => {
label={masqueradeMSG}
tooltip={masqueradeDisabledMSG}
>
<Switch size={"small"} disabled={!setupNewRouteHA} checked={formRoute.masquerade}/>
<Switch size={"small"} disabled={!setupNewRouteHA && !newRoute} checked={formRoute.masquerade}/>
</Form.Item>
</Col>
<Col span={24}>
@@ -343,6 +415,28 @@ const RouteUpdate = () => {
<InputNumber min={1} max={9999} autoComplete="off"/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="groups"
label="Distribution groups"
tooltip="NetBird will advertise this route to peers that belong to the following groups"
rules={[{validator: selectPreValidator}]}
>
<Select mode="tags"
style={{width: '100%'}}
placeholder="Associate groups with the network route"
tagRender={tagRender}
onChange={handleChangeTags}
dropdownRender={dropDownRender}
>
{
tagGroups.map(m =>
<Option key={m}>{optionRender(m)}</Option>
)
}
</Select>
</Form.Item>
</Col>
<Col span={24}>
<Row wrap={false} gutter={12}>
<Col flex="none">
@@ -350,7 +444,8 @@ const RouteUpdate = () => {
</Col>
<Col flex="auto">
<Paragraph>
You can enable high-availability by assigning the same network identifier and network CIDR to multiple routes.
You can enable high-availability by assigning the same network identifier
and network CIDR to multiple routes.
</Paragraph>
</Col>
</Row>
@@ -358,8 +453,7 @@ const RouteUpdate = () => {
<Col span={24}>
<Divider></Divider>
<Button icon={<QuestionCircleFilled/>} type="link" target="_blank"
href="https://netbird.io/docs/how-to-guides/network-routes" style={{color: 'rgb(07, 114, 128)'}}>Learn
more about network routes</Button>
href="https://netbird.io/docs/how-to-guides/network-routes">Learn more about network routes</Button>
</Col>
</Row>
</Form>

View File

@@ -10,7 +10,8 @@ import {
Drawer,
Form,
Input,
List,
InputNumber,
List, Modal,
Radio,
Row,
Select,
@@ -20,24 +21,29 @@ import {
} from "antd";
import {RootState} from "typesafe-actions";
import {CloseOutlined, EditOutlined, QuestionCircleFilled} from "@ant-design/icons";
import {SetupKey, SetupKeyToSave} from "../store/setup-key/types";
import {useOidcAccessToken} from "@axa-fr/react-oidc";
import {FormSetupKey, SetupKey, SetupKeyToSave} from "../store/setup-key/types";
import {Header} from "antd/es/layout/layout";
import {formatDate, timeAgo} from "../utils/common";
import {checkExpiresIn, formatDate, timeAgo} from "../utils/common";
import {RuleObject} from "antd/lib/form";
import {CustomTagProps} from "rc-select/lib/BaseSelect";
import {Group} from "../store/group/types";
import {useGetTokenSilently} from "../utils/token";
import ExpiresInInput, {expiresInToSeconds, ExpiresInValue} from "../views/ExpiresInInput";
import moment from "moment";
const {Option} = Select;
const {Text} = Typography;
const ExpiresInDefault: ExpiresInValue = {number: 30, interval: "day"}
const customExpiresFormat: DatePickerProps['format'] = value => {
return formatDate(value)
}
const customLastUsedFormat: DatePickerProps['format'] = value => {
if (value.toString().startsWith("0001")) {
if (value.year() == 1) {
// 1st of Jan 0001
return "never"
}
let ago = timeAgo(value.toString())
@@ -47,12 +53,8 @@ const customLastUsedFormat: DatePickerProps['format'] = value => {
return ago
}
interface FormSetupKey extends SetupKey {
autoGroupNames: string[]
}
const SetupKeyNew = () => {
const {accessToken} = useOidcAccessToken()
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const setupNewKeyVisible = useSelector((state: RootState) => state.setupKey.setupNewKeyVisible)
const setupKey = useSelector((state: RootState) => state.setupKey.setupKey)
@@ -84,7 +86,7 @@ const SetupKeyNew = () => {
allGroups.set(g.id!, g)
})
let formKeyGroups :string[] = []
let formKeyGroups: string[] = []
if (setupKey.auto_groups) {
formKeyGroups = setupKey.auto_groups.filter(g => allGroups.get(g)).map(g => allGroups.get(g)!.name)
@@ -93,6 +95,9 @@ const SetupKeyNew = () => {
const fSetupKey = {
...setupKey,
autoGroupNames: setupKey.auto_groups ? formKeyGroups : [],
expiresInFormatted: ExpiresInDefault,
exp: moment(setupKey.expires),
last: moment(setupKey.last_used)
} as FormSetupKey
setFormSetupKey(fSetupKey)
form.setFieldsValue(fSetupKey)
@@ -101,23 +106,28 @@ const SetupKeyNew = () => {
const createSetupKeyToSave = (): SetupKeyToSave => {
const autoGroups = groups?.filter(g => formSetupKey.autoGroupNames.includes(g.name)).map(g => g.id || '') || []
// find groups that do not yet exist (newly added by the user)
const allGroupsNames : string[] = groups?.map(g => g.name);
const allGroupsNames: string[] = groups?.map(g => g.name);
const groupsToCreate = formSetupKey.autoGroupNames.filter(s => !allGroupsNames.includes(s))
let expiresIn = expiresInToSeconds(formSetupKey.expiresInFormatted)
return {
id: formSetupKey.id,
name: formSetupKey.name,
type: formSetupKey.type,
auto_groups: autoGroups,
revoked: formSetupKey.revoked,
groupsToCreate: groupsToCreate
groupsToCreate: groupsToCreate,
expires_in: expiresIn,
usage_limit: formSetupKey.usage_limit
} as SetupKeyToSave
}
const handleFormSubmit = () => {
form.validateFields()
.then((values) => {
let setupKeyToSave = createSetupKeyToSave()
dispatch(setupKeyActions.saveSetupKey.request({
getAccessTokenSilently: accessToken,
getAccessTokenSilently: getTokenSilently,
payload: setupKeyToSave
}))
})
@@ -134,12 +144,15 @@ const SetupKeyNew = () => {
if (savedSetupKey.loading) return
dispatch(setupKeyActions.setSetupKey({
name: "",
type: "reusable",
type: "one-off",
key: "",
last_used: "",
expires: "",
state: "valid",
auto_groups: new Array()
auto_groups: new Array(),
usage_limit: 0,
used_times: 0,
expires_in: 0
} as SetupKey))
setFormSetupKey({} as FormSetupKey)
setVisibleNewSetupKey(false)
@@ -242,24 +255,42 @@ const SetupKeyNew = () => {
</>
)
const changesDetected = (): boolean => {
return formSetupKey.name == null || formSetupKey.name !== setupKey.name || groupsChanged()
|| formSetupKey.usage_limit !== setupKey.usage_limit
}
const groupsChanged = (): boolean => {
if (formSetupKey.autoGroupNames.length != setupKey.auto_groups.length) {
return true
}
const formGroupIds = groups?.filter(g => formSetupKey.autoGroupNames.includes(g.name)).map(g => g.id || '') || []
return setupKey.auto_groups?.filter(g => !formGroupIds.includes(g)).length > 0
}
return (
<>
{setupKey &&
<Drawer
forceRender={true}
headerStyle={{display: "none"}}
visible={setupNewKeyVisible}
open={setupNewKeyVisible}
bodyStyle={{paddingBottom: 80}}
onClose={onCancel}
footer={
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button disabled={savedSetupKey.loading} onClick={onCancel}>Cancel</Button>
<Button type="primary" disabled={savedSetupKey.loading}
<Button type="primary" disabled={savedSetupKey.loading || !changesDetected()}
onClick={handleFormSubmit}>{`${formSetupKey.id ? 'Save' : 'Create'}`}</Button>
</Space>
}
>
<Form layout="vertical" hideRequiredMark form={form} onValuesChange={onChange}>
<Form layout="vertical" hideRequiredMark form={form} onValuesChange={onChange}
initialValues={{
expiresInFormatted: ExpiresInDefault,
}}
>
<Row gutter={16}>
<Col span={24}>
<Header style={{margin: "-32px -24px 20px -24px", padding: "24px 24px 0 24px"}}>
@@ -290,7 +321,6 @@ const SetupKeyNew = () => {
message: 'Please add a new name for this peer',
whitespace: true
}]}
style={{display: 'flex'}}
>
<Input
placeholder={setupKey.name}
@@ -317,6 +347,7 @@ const SetupKeyNew = () => {
>
<Input
disabled={true}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>
@@ -325,11 +356,12 @@ const SetupKeyNew = () => {
{setupKey.id && formSetupKey.name &&
<Col span={12}>
<Form.Item
name="expires"
name="exp"
label="Expires"
tooltip="The expiration date of the key"
>
<DatePicker disabled={true} style={{width: '100%'}}
<DatePicker disabled={true}
style={{width: "100%", color: "#5a5c5a"}}
format={customExpiresFormat}/>
</Form.Item>
</Col>
@@ -337,11 +369,12 @@ const SetupKeyNew = () => {
{setupKey.id && formSetupKey.name &&
<Col span={12}>
<Form.Item
name="last_used"
name="last"
label="Last Used"
tooltip="The last time the key was used"
>
<DatePicker disabled={true} style={{width: '100%'}}
<DatePicker disabled={true}
style={{width: "100%", color: "#5a5c5a"}}
format={customLastUsedFormat}/>
</Form.Item>
</Col>
@@ -351,7 +384,6 @@ const SetupKeyNew = () => {
name="type"
label="Type"
rules={[{required: true, message: 'Please enter key type'}]}
style={{display: 'flex'}}
>
<Radio.Group style={{display: 'flex'}} disabled={setupKey.id}>
<Space direction="vertical" style={{flex: 1}}>
@@ -359,6 +391,14 @@ const SetupKeyNew = () => {
size="large"
bordered
>
<List.Item>
<Radio value={"one-off"}>
<Space direction="vertical" size="small">
<Text strong>One-off</Text>
<Text>This key can be used only once</Text>
</Space>
</Radio>
</List.Item>
<List.Item>
<Radio value={"reusable"}>
<Space direction="vertical" size="small">
@@ -368,14 +408,6 @@ const SetupKeyNew = () => {
</Space>
</Radio>
</List.Item>
<List.Item>
<Radio value={"one-off"}>
<Space direction="vertical" size="small">
<Text strong>One-off</Text>
<Text>This key can be used only once</Text>
</Space>
</Radio>
</List.Item>
</List>
</Space>
@@ -383,6 +415,38 @@ const SetupKeyNew = () => {
</Form.Item>
</Col>
{!setupKey.id &&
<Col span={24}>
<Form.Item name="expiresInFormatted" label="Expires In"
rules={[{validator: checkExpiresIn}]}>
<ExpiresInInput options={
Array.of(
{key: "day", title: "Days"},
{key: "month", title: "Months"},
{key: "year", title: "Years"})
}/>
</Form.Item>
</Col>}
<Col span={12}>
<Form.Item name="usage_limit"
label="Usage Limit"
tooltip="Limit the number of times this key can be used. Use 0 for unlimited use."
>
<InputNumber min={0} defaultValue={0}
disabled={setupKey.id || formSetupKey.type !== "reusable"}
style={{width: "100%"}}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="used_times"
label="Used Times"
>
<InputNumber min={0} defaultValue={0} disabled={true}
style={{width: "100%"}}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="autoGroupNames"
@@ -396,7 +460,7 @@ const SetupKeyNew = () => {
tagRender={tagRender}
onChange={handleChangeTags}
dropdownRender={dropDownRender}
// enabled only when we have a new key !setupkey.id or when the key is valid
// enabled only when we have a new key !setupkey.id or when the key is valid
disabled={!(!setupKey.id || setupKey.valid)}
>
{
@@ -410,9 +474,7 @@ const SetupKeyNew = () => {
<Col span={24}>
<Divider></Divider>
<Button icon={<QuestionCircleFilled/>} type="link" target="_blank"
href="https://netbird.io/docs/overview/setup-keys"
style={{color: 'rgb(07, 114, 128)'}}>Learn
more about setup keys</Button>
href="https://netbird.io/docs/overview/setup-keys">Learn more about setup keys</Button>
</Col>
</Row>
</Form>

View File

@@ -0,0 +1,414 @@
import React, {useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {Alert, Button, Col, Divider, Drawer, Form, Input, Modal, Row, Select, Space, Tag, Typography} from "antd";
import {RootState} from "typesafe-actions";
import {CloseOutlined, EditOutlined, ExclamationCircleOutlined} from "@ant-design/icons";
import {Header} from "antd/es/layout/layout";
import {Group} from "../store/group/types";
import {FormUser, User, UserToSave} from "../store/user/types";
import {RuleObject} from "antd/lib/form";
import {CustomTagProps} from "rc-select/lib/BaseSelect";
import {actions as userActions} from "../store/user";
import {useGetTokenSilently} from "../utils/token";
import {useOidcUser} from "@axa-fr/react-oidc";
const {Paragraph} = Typography;
const {confirm} = Modal;
const {Option} = Select;
const UserUpdate = () => {
const {oidcUser} = useOidcUser();
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const user = useSelector((state: RootState) => state.user.user)
const savedUser = useSelector((state: RootState) => state.user.savedUser)
const groups = useSelector((state: RootState) => state.group.data)
const users = useSelector((state: RootState) => state.user.data)
const updateUserDrawerVisible = useSelector((state: RootState) => state.user.updateUserDrawerVisible)
const [selectedTagGroups, setSelectedTagGroups] = useState([] as string[])
const [tagGroups, setTagGroups] = useState([] as string[])
const [editName, setEditName] = useState(false)
const inputNameRef = useRef<any>(null)
const [formUser, setFormUser] = useState({} as FormUser)
const [currentUser, setCurrentUser] = useState({} as User)
const [form] = Form.useForm()
useEffect(() => {
if (editName) inputNameRef.current!.focus({
cursor: 'end',
});
}, [editName]);
const toggleEditName = (status: boolean) => {
setEditName(status);
}
useEffect(() => {
setTagGroups(groups?.filter(g => g.name != "All").map(g => g.name) || [])
}, [groups])
useEffect(() => {
if (oidcUser && oidcUser.sub) {
const found = users.find(u => u.id == oidcUser.sub)
if (found) {
setCurrentUser(found)
}
} else {
setCurrentUser({} as User)
}
}, [oidcUser, users])
useEffect(() => {
if (!user) return
let allGroups = new Map<string, Group>();
groups.forEach(g => {
allGroups.set(g.id!, g)
})
if (!user.auto_groups) {
user.auto_groups = []
}
let formKeyGroups = user.auto_groups.filter(g => allGroups.get(g)).map(g => allGroups.get(g)!.name)
const fUser = {
...user,
autoGroupsNames: user.auto_groups ? formKeyGroups : [],
} as FormUser
setFormUser(fUser)
form.setFieldsValue(fUser)
}, [user])
const selectValidator = (_: RuleObject, value: string[]) => {
let hasSpaceNamed = []
value.forEach(function (v: string) {
if (!v.trim().length) {
hasSpaceNamed.push(v)
}
})
if (hasSpaceNamed.length) {
return Promise.reject(new Error("Group names with just spaces are not allowed"))
}
return Promise.resolve()
}
const tagRender = (props: CustomTagProps) => {
const {label, value, closable, onClose} = props;
const onPreventMouseDown = (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
};
return (
<Tag
color="blue"
onMouseDown={onPreventMouseDown}
closable={closable}
onClose={onClose}
style={{marginRight: 3}}
>
<strong>{value}</strong>
</Tag>
);
}
const optionRender = (label: string) => {
let peersCount = ''
const g = groups.find(_g => _g.name === label)
if (g) peersCount = ` - ${g.peers_count || 0} ${(!g.peers_count || parseInt(g.peers_count) !== 1) ? 'peers' : 'peer'} `
return (
<>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{label}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</>
)
}
const dropDownRender = (menu: React.ReactElement) => (
<>
{menu}
<Divider style={{margin: '8px 0'}}/>
<Row style={{padding: '0 8px 4px'}}>
<Col flex="auto">
<span style={{color: "#9CA3AF"}}>Add new group by pressing "Enter"</span>
</Col>
<Col flex="none">
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.70455 7.19176V5.89915H10.3949C10.7727 5.89915 11.1174 5.80634 11.429 5.62074C11.7405 5.43513 11.9875 5.18655 12.1697 4.875C12.3554 4.56345 12.4482 4.21875 12.4482 3.84091C12.4482 3.46307 12.3554 3.12003 12.1697 2.81179C11.9841 2.50024 11.7356 2.25166 11.424 2.06605C11.1158 1.88044 10.7727 1.78764 10.3949 1.78764H9.83807V0.5H10.3949C11.0114 0.5 11.5715 0.650805 12.0753 0.952414C12.5791 1.25402 12.9818 1.65672 13.2834 2.16051C13.585 2.6643 13.7358 3.22443 13.7358 3.84091C13.7358 4.30161 13.648 4.73414 13.4723 5.13849C13.3 5.54285 13.0613 5.89915 12.7564 6.20739C12.4515 6.51562 12.0968 6.75758 11.6925 6.93324C11.2881 7.10559 10.8556 7.19176 10.3949 7.19176H1.70455ZM4.90128 11.0646L0.382102 6.54545L4.90128 2.02628L5.79119 2.91619L2.15696 6.54545L5.79119 10.1747L4.90128 11.0646Z"
fill="#9CA3AF"/>
</svg>
</Col>
</Row>
</>
)
const handleChangeTags = (value: string[]) => {
let validatedValues: string[] = []
value.forEach(function (v) {
if (v.trim().length) {
validatedValues.push(v)
}
})
setSelectedTagGroups(validatedValues)
};
const createUserToSave = (): UserToSave => {
const autoGroups = groups?.filter(g => formUser.autoGroupsNames.includes(g.name)).map(g => g.id || '') || []
// find groups that do not yet exist (newly added by the user)
const allGroupsNames: string[] = groups?.map(g => g.name);
const groupsToCreate = formUser.autoGroupsNames.filter(s => !allGroupsNames.includes(s))
return {
id: formUser.id,
email: formUser.email,
role: formUser.role,
name: formUser.name,
groupsToCreate: groupsToCreate,
auto_groups: autoGroups,
} as UserToSave
}
const showConfirmChangeRole = (userToSave: UserToSave) => {
let content = <Paragraph>With this action, you will remove the administrative privileges of your user.
Your user will be limited to read-only operations in this account. Are you sure?</Paragraph>
let contentModule = <div>{content}</div>
let name = formUser ? formUser.email : ''
confirm({
icon: <ExclamationCircleOutlined/>,
title: "Update user \"" + name + "\"",
width: 600,
content: contentModule,
okType: 'danger',
onOk() {
dispatch(userActions.saveUser.request({
getAccessTokenSilently: getTokenSilently,
payload: userToSave
}))
},
onCancel() {
},
});
}
// check if currentUser (who is doing the modification) removes the administrative privileges from themselves
const isShowConfirmWarning = (userToSave: UserToSave): boolean => {
return currentUser.id == userToSave.id && currentUser.role === "admin" && userToSave.role !== "admin"
}
const handleFormSubmit = () => {
form.validateFields()
.then((values) => {
let userToSave = createUserToSave()
if (isShowConfirmWarning(userToSave)) {
showConfirmChangeRole(userToSave)
} else {
dispatch(userActions.saveUser.request({
getAccessTokenSilently: getTokenSilently,
payload: userToSave
}))
}
})
.catch((errorInfo) => {
console.log('errorInfo', errorInfo)
});
};
const onCancel = () => {
if (savedUser.loading) return
dispatch(userActions.setUser({
id: "",
email: "",
role: "",
status: "",
auto_groups: [],
name: user.name,
is_current: user.is_current,
} as User));
setFormUser({} as FormUser)
toggleEditName(false)
dispatch(userActions.setUpdateUserDrawerVisible(false));
}
const onChange = (data: any) => {
setFormUser({...formUser, ...data})
}
const changesDetected = (): boolean => {
return emailChanged() || nameChanged() || groupsChanged() || roleChanged()
}
const emailChanged = (): boolean => {
return formUser.email !== user.email
}
const roleChanged = (): boolean => {
return formUser.role !== user.role
}
const nameChanged = (): boolean => {
return formUser.name !== user.name
}
const groupsChanged = (): boolean => {
if (!formUser.autoGroupsNames) {
return false
}
if (formUser.autoGroupsNames.length != user.auto_groups.length) {
return true
}
const formGroupIds = groups?.filter(g => formUser.autoGroupsNames.includes(g.name)).map(g => g.id || '') || []
return user.auto_groups?.filter(g => !formGroupIds.includes(g)).length > 0
}
return (
<>
{user &&
<Drawer
forceRender={true}
headerStyle={{display: "none"}}
open={updateUserDrawerVisible}
bodyStyle={{paddingBottom: 80}}
onClose={onCancel}
footer={
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button disabled={savedUser.loading} onClick={onCancel}>Cancel</Button>
<Button type="primary" disabled={savedUser.loading || !changesDetected()}
onClick={handleFormSubmit}>{`${formUser.id ? 'Save' : 'Invite'}`}</Button>
</Space>
}
>
<Form layout="vertical" hideRequiredMark form={form} onValuesChange={onChange}
initialValues={{
["role"]: formUser.role
}}
>
<Row gutter={16}>
<Col span={24}>
<Header style={{margin: "-32px -24px 20px -24px", padding: "24px 24px 0 24px"}}>
<Row align="top">
{/*Close Icon*/}
<Col flex="none" style={{display: "flex"}}>
{!editName && user.id &&
<button type="button" aria-label="Close" className="ant-drawer-close"
style={{paddingTop: 3}}
onClick={onCancel}>
<span role="img" aria-label="close"
className="anticon anticon-close">
<CloseOutlined size={16}/>
</span>
</button>
}
</Col>
{/* Name Label*/}
<Col flex="auto">
{!editName && user.id && formUser.name !== "" ? (
<div className={"access-control input-text ant-drawer-title"}
onClick={() => toggleEditName(true)}>{formUser.name ? formUser.name : formUser.name}
<EditOutlined/></div>
) : (
<Form.Item
name="name"
label="Name"
rules={[{
required: false,
message: 'Please add a new name for this user',
whitespace: true
}]}
>
<Input
placeholder={formUser.name}
ref={inputNameRef}
onPressEnter={() => toggleEditName(false)}
onBlur={() => toggleEditName(false)}
autoComplete="off"/>
</Form.Item>)}
</Col>
</Row>
</Header>
</Col>
<Col span={24}>
<Form.Item
name="email"
label="Email"
>
<Input
disabled={user.id}
value={formUser.email}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="role"
label="Role"
>
<Select
style={{width: '100%'}}
disabled={(currentUser.role != null && currentUser.role !== "admin") || formUser.is_current}>
<Option value="admin">admin</Option>
<Option value="user">user</Option>
</Select>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="autoGroupsNames"
label="Auto-assigned groups"
tooltip="Every peer enrolled with this user will be automatically added to these groups"
rules={[{validator: selectValidator}]}
>
<Select mode="tags"
style={{width: '100%'}}
placeholder="Associate groups with the user"
tagRender={tagRender}
onChange={handleChangeTags}
disabled={currentUser.role != null && currentUser.role !== "admin"}
dropdownRender={dropDownRender}
>
{
tagGroups.map(m =>
<Option key={m}>{optionRender(m)}</Option>
)
}
</Select>
</Form.Item>
</Col>
<Col span={24}>
<Divider></Divider>
</Col>
{currentUser && currentUser.role !== "admin" && (
<div>
<Col span={24}>
<Alert
message={<div style={{color: "#5a5c5a"}}>
You are not an administrator, therefore you can't update users.</div>}
showIcon={false}
type="warning"/>
</Col>
<br></br>
</div>
)}
</Row>
</Form>
</Drawer>
}
</>
)
}
export default UserUpdate

View File

@@ -0,0 +1,100 @@
import React, {useState} from 'react';
import {Tabs, TabsProps} from "antd";
import Icon, {AndroidFilled, AppleFilled, WindowsFilled} from "@ant-design/icons";
import {ReactComponent as LinuxSVG} from "../icons/terminal_icon.svg";
import UbuntuTab from "./UbuntuTab";
import {ReactComponent as DockerSVG} from "../icons/docker_icon.svg";
import Paragraph from "antd/lib/typography/Paragraph";
import WindowsTab from "./WindowsTab";
import MacTab from "./MacTab";
import Link from "antd/lib/typography/Link";
import DockerTab from "./DockerTab";
type Props = {
greeting?: string;
headline: string;
};
const detectOSTab = () => {
let os = 1;
if (navigator.userAgent.indexOf("Win") !== -1) os = 2;
if (navigator.userAgent.indexOf("Mac") !== -1) os = 3;
if (navigator.userAgent.indexOf("X11") !== -1) os = 1;
if (navigator.userAgent.indexOf("Linux") !== -1) os = 1
return os
}
export const AddPeerPopup: React.FC<Props> = ({
greeting,
headline,
}) => {
const [openTab, setOpenTab] = useState(detectOSTab);
const [width, setWidth] = useState<number>(window.innerWidth);
const isMobile = width <= 768;
const items: TabsProps['items'] = [
{
key: "1",
label: <span><Icon component={LinuxSVG}/>Linux</span>,
children: <UbuntuTab/>,
},
{
key: "2",
label: <span><WindowsFilled/>Windows</span>,
children: <WindowsTab/>,
},
{
key: "3",
label: <span><AppleFilled/>macOS</span>,
children: <MacTab/>,
},
/*{
key: "4",
label: <span><AndroidFilled/>Android</span>,
children: <></>,
},*/
{
key: "5",
label: <span><Icon component={DockerSVG}/>Docker</span>,
children: <DockerTab/>,
}
];
return <>
{greeting && <Paragraph
style={{textAlign: "center", whiteSpace: "pre-line", fontSize: "2em", marginBottom: -10}}>
{greeting}
</Paragraph>}
<Paragraph
style={{textAlign: "center", whiteSpace: "pre-line", fontSize: "2em"}}>
{headline}
</Paragraph>
<Paragraph type={"secondary"}
style={{
marginTop: "-15px",
textAlign: "center",
whiteSpace: "pre-line",
}}>
To get started install NetBird and log in using your {"\n"} email account.
</Paragraph>
<Tabs centered={!isMobile}
defaultActiveKey={openTab.toString()} tabPosition="top" animated={{inkBar: true, tabPane: false}}
items={items}/>
<Paragraph type={"secondary"}
style={{
marginTop: "15px",
}}>
After that you should be connected. Add more devices to your network or manage your existing devices in the
admin panel.
If you have further questions check out our {<Link target="_blank"
href={"https://netbird.io/docs/getting-started/installation"}>installation
guide</Link>}
</Paragraph>
</>
}
export default AddPeerPopup

View File

@@ -0,0 +1,63 @@
import React, {useState} from 'react';
import {StepCommand} from "./types"
import {formatDockerCommand, formatNetBirdUP} from "./common"
import SyntaxHighlighter from "react-syntax-highlighter";
import TabSteps from "./TabSteps";
import {Button, Typography} from "antd";
import Link from "antd/lib/typography/Link";
const {Title, Paragraph, Text} = Typography;
export const DockerTab = () => {
const [steps, setSteps] = useState([
{
key: 1,
title: 'Install Docker',
commands: (
<Button style={{marginTop: "5px"}} type="primary" href="https://docs.docker.com/engine/install/" target="_blank">Official Docker website</Button>
),
copied: false,
showCopyButton: false
} as StepCommand,
{
key: 2,
title: 'Run NetBird container',
commands: formatDockerCommand(),
copied: false,
showCopyButton: false
} as StepCommand,
{
key: 3,
title: "Read docs",
commands: (
<Link href="https://netbird.io/docs/getting-started/installation#running-netbird-in-docker" target="_blank">Running NetBird in Docker</Link>
),
copied: false,
showCopyButton: false
} as StepCommand
])
return (
<div style={{marginTop: 10}}>
{/*<Text style={{fontWeight: "bold"}}>
Run in Docker
</Text>
<div style={{fontSize: ".85em", marginTop: 5, marginBottom: 25}}>
<SyntaxHighlighter language="bash">
{formatDockerCommand()}
</SyntaxHighlighter>
</div>*/}
<Text style={{fontWeight: "bold"}}>
Install on Ubuntu
</Text>
<div style={{marginTop: 5}}>
<TabSteps stepsItems={steps}/>
</div>
</div>
)
}
export default DockerTab

View File

@@ -1,17 +1,50 @@
import React, {useState} from 'react';
import { Button } from "antd";
import {Button, Typography} from "antd";
import TabSteps from "./TabSteps";
import { StepCommand } from "./types"
import {StepCommand} from "./types"
import {formatNetBirdUP} from "./common"
import {Collapse} from "antd";
import SyntaxHighlighter from "react-syntax-highlighter";
const { Panel } = Collapse;
const {Text} = Typography;
export const LinuxTab = () => {
const [quickSteps, setQuickSteps] = useState([
{
key: 1,
title: 'Download and run installer:',
commands: (
<Button style={{marginTop: "5px"}} type="primary" href="https://pkgs.netbird.io/windows/x64"
target="_blank">Download NetBird</Button>
),
copied: false
} as StepCommand,
{
key: 2,
title: 'Click on "Connect" from the NetBird icon in your system tray',
commands: '',
copied: false,
showCopyButton: false
},
{
key: 3,
title: 'Sign up using your email address',
commands: '',
copied: false,
showCopyButton: false
}
])
const [steps, setSteps] = useState([
{
key: 1,
title: 'Download and install Brew (package manager)',
title: 'Download and install Homebrew',
commands: (
<Button type="primary" href="https://brew.sh/" target="_blank">Download Brew</Button>
<Button style={{marginTop: "5px"}} type="primary" href="https://brew.sh/" target="_blank">Download
Brew</Button>
),
copied: false
} as StepCommand,
@@ -29,26 +62,51 @@ export const LinuxTab = () => {
} as StepCommand,
{
key: 3,
title: 'Run NetBird and log in the browser:',
title: 'Start NetBird daemon:',
commands: [
`sudo netbird up`
`sudo netbird service install`,
`sudo netbird service start`
].join('\n'),
copied: false,
showCopyButton: true
} as StepCommand,
{
key: 4,
title: 'Get your IP address:',
commands: [
`sudo ifconfig utun100`
].join('\n'),
title: 'Run NetBird and log in the browser:',
commands: formatNetBirdUP(),
copied: false,
showCopyButton: true
} as StepCommand
])
return (
<TabSteps stepsItems={steps}/>
<div style={{marginTop: 10}}>
<Text style={{fontWeight: "bold"}}>
Install with one command
</Text>
<div style={{fontSize: ".85em", marginTop: 5, marginBottom: 25}}>
<SyntaxHighlighter language="bash">
curl -fsSL https://pkgs.netbird.io/install.sh | sh
</SyntaxHighlighter>
</div>
<Text style={{fontWeight: "bold"}}>
Or install manually with HomeBrew
</Text>
<div style={{marginTop: 5}}>
<TabSteps stepsItems={steps}/>
</div>
</div>
/*<div style={{marginTop: 5}}>
<TabSteps stepsItems={quickSteps}/>
</div>*/
/*<div style={{marginTop: 10}}>
<Text style={{fontWeight: "bold"}}>
Install on macOS with Homebrew
</Text>
<div style={{marginTop: 5}}>
<TabSteps stepsItems={steps}/>
</div>
</div>*/
)
}

View File

@@ -2,16 +2,16 @@ import "highlight.js/styles/mono-blue.css";
import "highlight.js/lib/languages/bash";
import { StepCommand } from './types'
import SyntaxHighlighter from 'react-syntax-highlighter';
import { monoBlue } from 'react-syntax-highlighter/dist/esm/styles/hljs';
import {
Typography,
Space,
Steps, Button
Steps, Button, Popover, StepsProps
} from "antd";
import {copyToClipboard} from "../../utils/common";
import {CheckOutlined, CopyOutlined} from "@ant-design/icons";
import React, {useEffect, useState} from "react";
const { Step } = Steps;
const {Text} = Typography;
type Props = {
stepsItems: Array<StepCommand>
@@ -36,35 +36,22 @@ const TabSteps:React.FC<Props> = ({stepsItems}) => {
}, 2000)
}
}
return (
<Steps direction="vertical" current={0}>
<Steps direction="vertical" size={"small"}>
{steps.map(c =>
<Step
status={"process"}
key={c.key}
title={c.title}
title={<Text>{c.title}</Text>}
description={
<Space className="nb-code" direction="vertical" size="small" style={{display: "flex"}}>
<Space className="nb-code" direction="vertical" size="small" style={{display: "flex", fontSize: ".85em"}}>
{ (c.commands && (typeof c.commands === 'string')) ? (
<SyntaxHighlighter language="bash" style={monoBlue}>
<SyntaxHighlighter language="bash">
{c.commands}
</SyntaxHighlighter>
) : (
c.commands
)}
{ c.showCopyButton &&
<>
{ !c.copied ? (
<Button type="text" size="large" className="btn-copy-code" icon={<CopyOutlined/>}
style={{color: "rgb(107, 114, 128)"}}
onClick={() => onCopyClick(c.key, c.commands, true)}/>
): (
<Button type="text" size="large" className="btn-copy-code" icon={<CheckOutlined/>}
style={{color: "green"}}/>
)}
</>
}
</Space>
}
/>

View File

@@ -1,40 +1,29 @@
import React, { useState } from 'react';
import { Button } from "antd";
import React, {useState} from 'react';
import {StepCommand} from "./types"
import {formatNetBirdUP} from "./common"
import SyntaxHighlighter from "react-syntax-highlighter";
import TabSteps from "./TabSteps";
import { StepCommand } from "./types"
import { getConfig } from "../../config";
import Paragraph from 'antd/lib/skeleton/Paragraph';
const { grpcApiOrigin } = getConfig();
import {Typography} from "antd";
const {Title, Paragraph, Text} = Typography;
export const UbuntuTab = () => {
const formatNetBirdUP = () => {
let cmd = "sudo netbird up"
if (grpcApiOrigin) {
cmd = "sudo netbird up --management-url " + grpcApiOrigin
}
return [
cmd
].join('\n')
}
const [steps, setSteps] = useState([
{
key: 1,
title: 'Add repository:',
title: 'Add repository',
commands: [
`sudo apt install ca-certificates curl gnupg -y`,
`curl -L https://pkgs.wiretrustee.com/debian/public.key | sudo apt-key add -`,
`curl -L https://pkgs.wiretrustee.com/debian/public.key | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/wiretrustee.gpg`,
`echo 'deb https://pkgs.wiretrustee.com/debian stable main' | sudo tee /etc/apt/sources.list.d/wiretrustee.list`
].join('\n'),
copied: false,
showCopyButton: true
showCopyButton: false
} as StepCommand,
{
key: 2,
title: 'Install NetBird:',
title: 'Install NetBird',
commands: [
`sudo apt-get update`,
`# for CLI only`,
@@ -43,39 +32,36 @@ export const UbuntuTab = () => {
`sudo apt-get install netbird-ui`
].join('\n'),
copied: false,
showCopyButton: true
showCopyButton: false
} as StepCommand,
{
key: 3,
title: 'Run NetBird and log in the browser:',
title: 'Run NetBird and log in the browser',
commands: formatNetBirdUP(),
copied: false,
showCopyButton: true
} as StepCommand,
{
key: 4,
title: 'Get your IP address:',
commands: [
`ip addr show wt0`
].join('\n'),
copied: false,
showCopyButton: true
} as StepCommand,
showCopyButton: false
} as StepCommand
])
/*const clickTest = () => {
steps.push({
key: steps.length+1,
title: `Test ${steps.length+1}`,
commands: [`hi lorena!`].join('\n'),
copied: false
})
console.log(steps)
setSteps([...steps])
}*/
return (
<TabSteps stepsItems={steps} />
<div style={{marginTop: 10}}>
<Text style={{fontWeight: "bold"}}>
Install with one command
</Text>
<div style={{fontSize: ".85em", marginTop: 5, marginBottom: 25}}>
<SyntaxHighlighter language="bash">
curl -fsSL https://pkgs.netbird.io/install.sh | sh
</SyntaxHighlighter>
</div>
<Text style={{fontWeight: "bold"}}>
Or install manually on Ubuntu
</Text>
<div style={{marginTop: 5}}>
<TabSteps stepsItems={steps}/>
</div>
</div>
)
}

View File

@@ -1,33 +1,31 @@
import React, {useState} from 'react';
import { Button } from "antd";
import {Button, Typography} from "antd";
import TabSteps from "./TabSteps";
import { StepCommand } from "./types"
import {getConfig} from "../../config";
const {latestVersion} = getConfig();
const {Text} = Typography;
export const WindowsTab = () => {
const releaseVersion = latestVersion ? latestVersion.replace("v", "") : "0.6.3"
const [steps, setSteps] = useState([
{
key: 1,
title: 'Download and run Windows installer:',
commands: (
<Button type="primary" href={`https://github.com/netbirdio/netbird/releases/download/v${releaseVersion}/netbird_installer_${releaseVersion}_windows_amd64.exe`} target="_blank">Download NetBird</Button>
<Button style={{marginTop: "5px"}} type="primary" href="https://pkgs.netbird.io/windows/x64" target="_blank">Download NetBird</Button>
),
copied: false
} as StepCommand,
{
key: 2,
title: 'Click on "Connect" from the NetBird icon in your system tray.',
title: 'Click on "Connect" from the NetBird icon in your system tray',
commands: '',
copied: false,
showCopyButton: false
},
{
key: 3,
title: 'Log in your browser.\n',
title: 'Sign up using your email address',
commands: '',
copied: false,
showCopyButton: false
@@ -35,7 +33,15 @@ export const WindowsTab = () => {
])
return (
<TabSteps stepsItems={steps}/>
<div style={{marginTop: 10}}>
<Text style={{fontWeight: "bold"}}>
Install on Windows
</Text>
<div style={{marginTop: 5}}>
<TabSteps stepsItems={steps}/>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import {getConfig} from "../../config";
const { grpcApiOrigin } = getConfig();
export const formatNetBirdUP = () => {
let cmd = "netbird up"
if (grpcApiOrigin) {
cmd = "netbird up --management-url " + grpcApiOrigin
}
return [
cmd
].join('\n')
}
export const formatDockerCommand = () => {
let cmd = ["docker run --rm -d",
" --cap-add=NET_ADMIN",
" -e NB_SETUP_KEY=SETUP_KEY",
" -v netbird-client:/etc/netbird"]
if (grpcApiOrigin) {
cmd.push(" -e NB_MANAGEMENT_URL="+grpcApiOrigin)
}
cmd.push(" netbirdio/netbird:latest")
return cmd.join(' \\\n')
}

View File

@@ -2,7 +2,7 @@ import * as React from "react";
export interface StepCommand {
key: number | string,
title: string,
title: React.ReactNode | string | null,
commands: React.ReactNode | string | null,
copied?: boolean,
showCopyButton?: boolean

View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><svg data-name="Layer 1" id="Layer_1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M507,211.16c-1.42-1.19-14.25-10.94-41.79-10.94a132.55,132.55,0,0,0-21.61,1.9c-5.22-36.4-35.38-54-36.57-55l-7.36-4.28-4.75,6.9a101.65,101.65,0,0,0-13.06,30.45c-5,20.7-1.9,40.2,8.55,56.85-12.59,7.14-33,8.8-37.28,9H15.94A15.93,15.93,0,0,0,0,262.07,241.25,241.25,0,0,0,14.75,348.9C26.39,379.35,43.72,402,66,415.74,91.22,431.2,132.3,440,178.6,440a344.23,344.23,0,0,0,62.45-5.71,257.44,257.44,0,0,0,81.69-29.73,223.55,223.55,0,0,0,55.57-45.67c26.83-30.21,42.74-64,54.38-94h4.75c29.21,0,47.26-11.66,57.23-21.65a63.31,63.31,0,0,0,15.2-22.36l2.14-6.18Z"/><path d="M47.29,236.37H92.4a4,4,0,0,0,4-4h0V191.89a4,4,0,0,0-4-4H47.29a4,4,0,0,0-4,4h0v40.44a4.16,4.16,0,0,0,4,4h0"/><path d="M109.5,236.37h45.12a4,4,0,0,0,4-4h0V191.89a4,4,0,0,0-4-4H109.5a4,4,0,0,0-4,4v40.44a4.16,4.16,0,0,0,4,4"/><path d="M172.9,236.37H218a4,4,0,0,0,4-4h0V191.89a4,4,0,0,0-4-4H172.9a4,4,0,0,0-4,4h0v40.44a3.87,3.87,0,0,0,4,4h0"/><path d="M235.36,236.37h45.12a4,4,0,0,0,4-4V191.89a4,4,0,0,0-4-4H235.36a4,4,0,0,0-4,4h0v40.44a4,4,0,0,0,4,4h0"/><path d="M109.5,178.57h45.12a4.16,4.16,0,0,0,4-4V134.09a4,4,0,0,0-4-4H109.5a4,4,0,0,0-4,4v40.44a4.34,4.34,0,0,0,4,4"/><path d="M172.9,178.57H218a4.16,4.16,0,0,0,4-4V134.09a4,4,0,0,0-4-4H172.9a4,4,0,0,0-4,4h0v40.44a4,4,0,0,0,4,4"/><path d="M235.36,178.57h45.12a4.16,4.16,0,0,0,4-4V134.09a4.16,4.16,0,0,0-4-4H235.36a4,4,0,0,0-4,4h0v40.44a4.16,4.16,0,0,0,4,4"/><path d="M235.36,120.53h45.12a4,4,0,0,0,4-4V76a4.16,4.16,0,0,0-4-4H235.36a4,4,0,0,0-4,4h0v40.44a4.17,4.17,0,0,0,4,4"/><path d="M298.28,236.37H343.4a4,4,0,0,0,4-4V191.89a4,4,0,0,0-4-4H298.28a4,4,0,0,0-4,4h0v40.44a4.16,4.16,0,0,0,4,4"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><title/><path d="M432,32H80A64.07,64.07,0,0,0,16,96V416a64.07,64.07,0,0,0,64,64H432a64.07,64.07,0,0,0,64-64V96A64.07,64.07,0,0,0,432,32ZM96,256a16,16,0,0,1-10-28.49L150.39,176,86,124.49a16,16,0,1,1,20-25l80,64a16,16,0,0,1,0,25l-80,64A16,16,0,0,1,96,256Zm160,0H192a16,16,0,0,1,0-32h64a16,16,0,0,1,0,32Z"/></svg>

After

Width:  |  Height:  |  Size: 419 B

View File

@@ -7,5 +7,8 @@
"apiOrigin": "$NETBIRD_MGMT_API_ENDPOINT",
"grpcApiOrigin": "$NETBIRD_MGMT_GRPC_API_ENDPOINT",
"latestVersion": "$NETBIRD_LATEST_VERSION"
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
"redirectURI": "$AUTH_REDIRECT_URI",
"silentRedirectURI": "$AUTH_SILENT_REDIRECT_URI",
"tokenSource": "$NETBIRD_TOKEN_SOURCE"
}

View File

@@ -7,7 +7,24 @@ if (process.env.NODE_ENV !== 'production') {
configJson = require("./config.json");
}
const defaultRedirectURI = '/#callback';
const defaultSilentRedirectURI = '/#silent-callback'
const defaultTokenSource = "accessToken"
export function getConfig() {
let redirectURI = defaultRedirectURI
if (configJson.redirectURI) {
redirectURI = configJson.redirectURI
}
let silentRedirectURI = defaultSilentRedirectURI
if (configJson.silentRedirectURI) {
silentRedirectURI = configJson.silentRedirectURI
}
let tokenSource = defaultTokenSource
if (configJson.tokenSource) {
tokenSource = configJson.tokenSource
}
return {
auth0Auth: configJson.auth0Auth == "true", //due to substitution we can't use boolean in the config
@@ -16,7 +33,10 @@ export function getConfig() {
scopesSupported: configJson.authScopesSupported,
apiOrigin: configJson.apiOrigin,
grpcApiOrigin: configJson.grpcApiOrigin,
latestVersion: configJson.latestVersion,
audience: configJson.authAudience,
hotjarTrackID: configJson.hotjarTrackID,
redirectURI: redirectURI,
silentRedirectURI: silentRedirectURI,
tokenSource: tokenSource,
};
}

View File

@@ -1,8 +1,4 @@
@import '~antd/dist/antd.css';
/*@tailwind base;*/
/*@tailwind components;*/
/*@tailwind utilities;*/
@import 'antd/dist/reset.css';
body {
font-size: 16px;
@@ -92,20 +88,6 @@ body {
align-self: center;
}
.ant-steps-item-tail::after {
background-color: #1890ff !important;
}
.ant-steps-item-icon {
background: #1890ff !important;
color: #ffffff !important;
border: none;
}
.ant-steps-icon {
color: #ffffff !important;
}
.nb-code {
margin-bottom: 1em;
}
@@ -135,4 +117,30 @@ body {
.access-control.ant-drawer-subtitle {
line-height: 22px;
margin: 24px 0;
}
.ant-steps-item-icon{
background: #EBEBEB !important;
border-color: #EBEBEB !important;
}
.ant-steps-icon-dot{
background: #EBEBEB !important;
border-color: #EBEBEB !important;
}
.ant-steps-icon {
background: #EBEBEB !important;
border: none;
color: black !important;
}
.ant-steps-item-tail::after {
background: #EBEBEB !important;
}
.ant-steps-item-tail {
border: none;
}
.ant-table-thead .ant-table-cell {
font-weight: normal !important;
}

View File

@@ -26,9 +26,9 @@ const auth0AuthorityConfig: AuthorityConfiguration = {
const providerConfig = {
authority: config.authority,
client_id: config.clientId,
redirect_uri: window.location.origin + '/#callback',
redirect_uri: window.location.origin + config.redirectURI,
refresh_time_before_tokens_expiration_in_second: 30,
silent_redirect_uri: window.location.origin + '/#silent-callback',
silent_redirect_uri: window.location.origin + config.silentRedirectURI,
scope: config.scopesSupported,
// disabling service worker
// service_worker_relative_url:'/OidcServiceWorker.js',
@@ -41,7 +41,7 @@ const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
const loadingComponent = () => <Loading padding="3em" width="50px" height="50px"/>
const loadingComponent = () => <Loading padding="3em" width={50} height={50}/>
root.render(
<OidcProvider

View File

@@ -1,14 +1,8 @@
import axios, {AxiosError} from 'axios';
import axios from 'axios';
import {ApiRequestParams, ApiResponse, ApiError} from './types';
import {ApiError, ApiRequestParams, ApiResponse} from './types';
import {headersFactory, RequestHeader} from './header-factory';
/*axios.interceptors.response.use(undefined, err => {
let res = err.response;
if (res.status === 401) {
}
})*/
async function apiRequest<T>(params: ApiRequestParams): Promise<ApiResponse<T>> {
const data = params.data ? (params.data as any).payload : undefined;
const url = `${params.urlBase}${params.url}`;
@@ -16,10 +10,10 @@ async function apiRequest<T>(params: ApiRequestParams): Promise<ApiResponse<T>>
const extraHeaders = params.extraHeaders || {};
const headers = await headersFactory((params.data as any).getAccessTokenSilently);
const builtHeader: RequestHeader = { ...headers, ...extraHeaders };
const builtHeader: RequestHeader = {...headers, ...extraHeaders};
let response;
let error:ApiError = {
let error: ApiError = {
code: '-1',
message: '',
data: null,
@@ -27,7 +21,7 @@ async function apiRequest<T>(params: ApiRequestParams): Promise<ApiResponse<T>>
};
try {
response = await axios.request({ url, data, method: params.method, headers: builtHeader as any });
response = await axios.request({url, data, method: params.method, headers: builtHeader as any});
} catch (err: any) {
error = <ApiError>{
code: err ? err.code : '-1',
@@ -39,12 +33,15 @@ async function apiRequest<T>(params: ApiRequestParams): Promise<ApiResponse<T>>
let old = error.message
error.message = old + ". Please refresh the page if the issue continues."
error.code = 'ERR_UNAUTHORIZED'
} else if (error.statusCode === 403) {
error.code = 'ERR_FORBIDDEN'
console.log(error)
}
console.log(error)
throw error;
}
return { statusCode: response ? response.status : error.statusCode, body: response ? response.data : error };
return {statusCode: response ? response.status : error.statusCode, body: response ? response.data : error};
}
export { apiRequest };
export {apiRequest};

View File

@@ -9,11 +9,7 @@ const headersFactory = async (getAccessTokenSilently:any): Promise<RequestHeader
'Accept': 'application/json'
};
//const token = await getLocalItem<string>(StorageKey.token);
//const token = ''
// const token = await getAccessTokenSilently()
const token = getAccessTokenSilently
const token = await getAccessTokenSilently() as string
if (token) {
headers.authorization = `Bearer ${token}`;
}

View File

@@ -1,29 +1,30 @@
export enum StorageKey {
token
token,
hadFirstRun
}
const setLocalItem = async <T>(key: StorageKey, value: T): Promise<void> => {
try {
localStorage.setItem(`@net-bird:${key}`, JSON.stringify(value));
} catch (err) {
console.log(err);
}
try {
localStorage.setItem(`@net-bird:${key}`, JSON.stringify(value));
} catch (err) {
console.log(err);
}
};
const getLocalItem = async <T>(key: StorageKey): Promise<T | null> => {
try {
const item = localStorage.getItem(`@net-bird:${key}`);
if (!item) {
return null;
try {
const item = localStorage.getItem(`@net-bird:${key}`);
if (!item) {
return null;
}
return JSON.parse(item) as T;
} catch (err) {
console.log(err);
}
return JSON.parse(item) as T;
} catch (err) {
console.log(err);
}
return null;
return null;
};
export {
getLocalItem,
setLocalItem
getLocalItem,
setLocalItem
}

View File

@@ -0,0 +1,23 @@
import {ActionType, createAction, createAsyncAction} from 'typesafe-actions';
import {Account} from './types';
import {ApiError, ChangeResponse, RequestPayload} from '../../services/api-client/types';
const actions = {
getAccounts: createAsyncAction(
'GET_ACCOUNTS_REQUEST',
'GET_ACCOUNTS_SUCCESS',
'GET_ACCOUNTS_FAILURE',
)<RequestPayload<null>, Account[], ApiError>(),
updateAccount: createAsyncAction(
'UPDATE_ACCOUNT',
'UPDATE_ACCOUNT_SUCCESS',
'UPDATE_ACCOUNT_FAILURE',
)<RequestPayload<Account>, ChangeResponse<Account | null>, ChangeResponse<Account | null>>(),
setUpdateAccount: createAction('SET_UPDATED_ACCOUNT')<ChangeResponse<Account | null>>(),
resetUpdateAccount: createAction('RESET_UPDATED_ACCOUNT')<null>(),
};
export type ActionTypes = ActionType<typeof actions>;
export default actions;

View File

@@ -0,0 +1,7 @@
import actions, { ActionTypes as _actionTypes } from './actions';
import reducer from './reducer';
import sagas from './sagas';
export type ActionTypes = _actionTypes;
export { actions, reducer, sagas };

View File

@@ -0,0 +1,54 @@
import { createReducer } from 'typesafe-actions';
import { combineReducers } from 'redux';
import { Account } from './types';
import actions, { ActionTypes } from './actions';
import {ApiError, ChangeResponse} from "../../services/api-client/types";
type StateType = Readonly<{
data: Account[] | null;
loading: boolean;
failed: ApiError | null;
savedAccount: ChangeResponse<Account | null>;
}>;
const initialState: StateType = {
data: [],
loading: false,
failed: null,
savedAccount: <ChangeResponse<Account | null>>{
loading: false,
success: false,
failure: false,
error: null,
data : null
},
};
const data = createReducer<Account[], ActionTypes>(initialState.data as Account[])
.handleAction(actions.getAccounts.success,(_, action) => action.payload)
.handleAction(actions.getAccounts.failure, () => []);
const loading = createReducer<boolean, ActionTypes>(initialState.loading)
.handleAction(actions.getAccounts.request, () => true)
.handleAction(actions.getAccounts.success, () => false)
.handleAction(actions.getAccounts.failure, () => false);
const failed = createReducer<ApiError | null, ActionTypes>(initialState.failed)
.handleAction(actions.getAccounts.request, () => null)
.handleAction(actions.getAccounts.success, () => null)
.handleAction(actions.getAccounts.failure, (store, action) => action.payload);
const updatedAccount = createReducer<ChangeResponse<Account | null>, ActionTypes>(initialState.savedAccount)
.handleAction(actions.updateAccount.request, () => initialState.savedAccount)
.handleAction(actions.updateAccount.success, (store, action) => action.payload)
.handleAction(actions.updateAccount.failure, (store, action) => action.payload)
.handleAction(actions.setUpdateAccount, (store, action) => action.payload)
.handleAction(actions.resetUpdateAccount, () => initialState.savedAccount)
export default combineReducers({
data,
loading,
failed,
updatedAccount
});

View File

@@ -0,0 +1,64 @@
import {all, call, put, takeLatest} from 'redux-saga/effects';
import {ApiError, ApiResponse, ChangeResponse} from '../../services/api-client/types';
import {Account} from './types'
import service from './service';
import actions from './actions';
export function* getAccounts(action: ReturnType<typeof actions.getAccounts.request>): Generator {
try {
const effect = yield call(service.getAccounts, action.payload);
const response = effect as ApiResponse<Account[]>;
yield put(actions.getAccounts.success(response.body));
} catch (err) {
yield put(actions.getAccounts.failure(err as ApiError));
}
}
export function* updateAccount(action: ReturnType<typeof actions.updateAccount.request>): Generator {
try {
yield put(actions.setUpdateAccount({
loading: true,
success: false,
failure: false,
error: null,
data: null
}))
const account = action.payload.payload
const payloadToSave = {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: account
}
const effect = yield call(service.updateAccount, payloadToSave)
const response = effect as ApiResponse<Account>;
yield put(actions.updateAccount.success({
loading: false,
success: true,
failure: false,
error: null,
data: response.body
} as ChangeResponse<Account | null>));
} catch (err) {
console.log(err)
yield put(actions.updateAccount.failure({
loading: false,
success: false,
failure: true,
error: err as ApiError,
data: null
} as ChangeResponse<Account | null>));
}
}
export default function* sagas(): Generator {
yield all([
takeLatest(actions.getAccounts.request, getAccounts),
takeLatest(actions.updateAccount.request, updateAccount),
]);
}

View File

@@ -0,0 +1,19 @@
import {ApiResponse, RequestPayload} from '../../services/api-client/types';
import { apiClient } from '../../services/api-client';
import {Account} from './types';
export default {
async getAccounts(payload:RequestPayload<null>): Promise<ApiResponse<Account[]>> {
return apiClient.get<Account[]>(
`/api/accounts`,
payload
);
},
async updateAccount(payload:RequestPayload<Account>): Promise<ApiResponse<Account>> {
const id = payload.payload.id
return apiClient.put<Account>(
`/api/accounts/${id}`,
payload
);
}
};

View File

@@ -0,0 +1,11 @@
import {ExpiresInValue} from "../../views/ExpiresInInput";
export interface Account {
id: string;
settings: { peer_login_expiration_enabled: boolean, peer_login_expiration: number}
}
export interface FormAccount extends Account {
peer_login_expiration_enabled: boolean
peer_login_expiration_formatted : ExpiresInValue
}

View File

@@ -0,0 +1,26 @@
import { ActionType, createAction, createAsyncAction } from 'typesafe-actions';
import {DNSSettings, DNSSettingsToSave} from './types';
import {ApiError, CreateResponse, RequestPayload} from '../../services/api-client/types';
const actions = {
getDNSSettings: createAsyncAction(
'GET_DNSSettings_REQUEST',
'GET_DNSSettings_SUCCESS',
'GET_DNSSettings_FAILURE',
)<RequestPayload<null>, DNSSettings, ApiError>(),
saveDNSSettings: createAsyncAction(
'SAVE_DNSSettings_REQUEST',
'SAVE_DNSSettings_SUCCESS',
'SAVE_DNSSettings_FAILURE',
)<RequestPayload<DNSSettingsToSave>, CreateResponse<DNSSettings | null>, CreateResponse<DNSSettings | null>>(),
setSavedDNSSettings: createAction('SET_CREATE_DNSSettings')<CreateResponse<DNSSettings | null>>(),
resetSavedDNSSettings: createAction('RESET_CREATE_DNSSettings')<null>(),
setDNSSettings: createAction('SET_DNSSettings')<DNSSettings>(),
setSetupNewDNSSettingsVisible: createAction('SET_SETUP_NEW_DNSSettings_VISIBLE')<boolean>(),
setSetupNewDNSSettingsHA: createAction('SET_SETUP_NEW_DNSSettings_HA')<boolean>()
};
export type ActionTypes = ActionType<typeof actions>;
export default actions;

View File

@@ -0,0 +1,7 @@
import actions, { ActionTypes as _actionTypes } from './actions';
import reducer from './reducer';
import sagas from './sagas';
export type ActionTypes = _actionTypes;
export { actions, reducer, sagas };

View File

@@ -0,0 +1,79 @@
import { createReducer } from 'typesafe-actions';
import { combineReducers } from 'redux';
import { DNSSettings } from './types';
import actions, { ActionTypes } from './actions';
import {ApiError, CreateResponse} from "../../services/api-client/types";
type StateType = Readonly<{
data: DNSSettings | null;
dnsSettings: DNSSettings | null;
loading: boolean;
failed: ApiError | null;
saving: boolean;
savedDNSSettings: CreateResponse<DNSSettings | null>;
setupNewDNSSettingsVisible: boolean;
setupNewDNSSettingsHA: boolean
}>;
const initialState: StateType = {
data: null,
dnsSettings: null,
loading: false,
failed: null,
saving: false,
savedDNSSettings: <CreateResponse<DNSSettings | null>>{
loading: false,
success: false,
failure: false,
error: null,
data : null
},
setupNewDNSSettingsVisible: false,
setupNewDNSSettingsHA: false
};
const data = createReducer<DNSSettings, ActionTypes>(initialState.data as DNSSettings)
.handleAction(actions.getDNSSettings.success,(settings, action) => action.payload)
.handleAction(actions.getDNSSettings.failure,(settings, _) => settings);
const dnsSettings = createReducer<DNSSettings, ActionTypes>(initialState.dnsSettings as DNSSettings)
.handleAction(actions.setDNSSettings, (store, action) => action.payload);
const loading = createReducer<boolean, ActionTypes>(initialState.loading)
.handleAction(actions.getDNSSettings.request, () => true)
.handleAction(actions.getDNSSettings.success, () => false)
.handleAction(actions.getDNSSettings.failure, () => false);
const failed = createReducer<ApiError | null, ActionTypes>(initialState.failed)
.handleAction(actions.getDNSSettings.request, () => null)
.handleAction(actions.getDNSSettings.success, () => null)
.handleAction(actions.getDNSSettings.failure, (store, action) => action.payload);
const saving = createReducer<boolean, ActionTypes>(initialState.saving)
.handleAction(actions.getDNSSettings.request, () => true)
.handleAction(actions.getDNSSettings.success, () => false)
.handleAction(actions.getDNSSettings.failure, () => false);
const savedDNSSettings = createReducer<CreateResponse<DNSSettings | null>, ActionTypes>(initialState.savedDNSSettings)
.handleAction(actions.saveDNSSettings.request, () => initialState.savedDNSSettings)
.handleAction(actions.saveDNSSettings.success, (store, action) => action.payload)
.handleAction(actions.saveDNSSettings.failure, (store, action) => action.payload)
.handleAction(actions.setSavedDNSSettings, (store, action) => action.payload)
.handleAction(actions.resetSavedDNSSettings, () => initialState.savedDNSSettings)
const setupNewDNSSettingsVisible = createReducer<boolean, ActionTypes>(initialState.setupNewDNSSettingsVisible)
.handleAction(actions.setSetupNewDNSSettingsVisible, (store, action) => action.payload)
const setupNewDNSSettingsHA = createReducer<boolean, ActionTypes>(initialState.setupNewDNSSettingsHA)
.handleAction(actions.setSetupNewDNSSettingsHA, (store, action) => action.payload)
export default combineReducers({
data,
dnsSettings,
loading,
failed,
saving,
savedDNSSettings,
setupNewDNSSettingsVisible,
setupNewDNSSettingsHA
});

View File

@@ -0,0 +1,95 @@
import {all, call, put, takeLatest} from 'redux-saga/effects';
import {ApiError, ApiResponse, CreateResponse} from '../../services/api-client/types';
import {DNSSettings} from './types'
import service from './service';
import actions from './actions';
import serviceGroup from "../group/service";
import {Group} from "../group/types";
import {actions as groupActions} from "../group";
export function* getDNSSettings(action: ReturnType<typeof actions.getDNSSettings.request>): Generator {
try {
const effect = yield call(service.getDNSSettings, action.payload);
const response = effect as ApiResponse<DNSSettings>;
yield put(actions.getDNSSettings.success(response.body));
} catch (err) {
yield put(actions.getDNSSettings.failure(err as ApiError));
}
}
export function* saveDNSSettings(action: ReturnType<typeof actions.saveDNSSettings.request>): Generator {
try {
yield put(actions.setSavedDNSSettings({
loading: true,
success: false,
failure: false,
error: null,
data: null
} as CreateResponse<DNSSettings | null>))
const settingsToSave = action.payload.payload
let groupsToCreate = settingsToSave.groupsToCreate
if (!groupsToCreate) {
groupsToCreate = []
}
// first, create groups that were newly added by user
const responsesGroup = yield all(groupsToCreate.map(g => call(serviceGroup.createGroup, {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: {name: g}
})
))
const resGroups = (responsesGroup as ApiResponse<Group>[]).filter(r => r.statusCode === 200).map(g => (g.body as Group)).map(g => g.id)
const newGroups = [...settingsToSave.disabled_management_groups, ...resGroups]
const payloadToSave = {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: {
disabled_management_groups: newGroups,
} as DNSSettings
}
let effect = yield call(service.editDNSSettings, payloadToSave);
const response = effect as ApiResponse<DNSSettings>;
yield put(actions.saveDNSSettings.success({
loading: false,
success: true,
failure: false,
error: null,
data: response.body
} as CreateResponse<DNSSettings | null>));
yield put(groupActions.getGroups.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
yield put(actions.getDNSSettings.request({ getAccessTokenSilently: action.payload.getAccessTokenSilently, payload: null }));
} catch (err) {
yield put(groupActions.getGroups.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
yield put(actions.saveDNSSettings.failure({
loading: false,
success: false,
failure: true,
error: err as ApiError,
data: null
} as CreateResponse<DNSSettings | null>));
}
}
export default function* sagas(): Generator {
yield all([
takeLatest(actions.getDNSSettings.request, getDNSSettings),
takeLatest(actions.saveDNSSettings.request, saveDNSSettings),
]);
}

View File

@@ -0,0 +1,18 @@
import {ApiResponse, RequestPayload} from '../../services/api-client/types';
import { apiClient } from '../../services/api-client';
import { DNSSettings } from './types';
export default {
async getDNSSettings(payload:RequestPayload<null>): Promise<ApiResponse<DNSSettings>> {
return apiClient.get<DNSSettings>(
`/api/dns/settings`,
payload
);
},
async editDNSSettings(payload:RequestPayload<DNSSettings>): Promise<ApiResponse<DNSSettings>> {
return apiClient.put<DNSSettings>(
`/api/dns/settings`,
payload
);
},
};

View File

@@ -0,0 +1,8 @@
export interface DNSSettings {
disabled_management_groups: string[]
}
export interface DNSSettingsToSave extends DNSSettings
{
groupsToCreate: string[]
}

View File

@@ -0,0 +1,14 @@
import {ActionType, createAsyncAction} from 'typesafe-actions';
import {Event} from './types';
import {ApiError, RequestPayload} from '../../services/api-client/types';
const actions = {
getEvents: createAsyncAction(
'GET_EVENTS_REQUEST',
'GET_EVENTS_SUCCESS',
'GET_EVENTS_FAILURE',
)<RequestPayload<null>, Event[], ApiError>(),
};
export type ActionTypes = ActionType<typeof actions>;
export default actions;

7
src/store/event/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import actions, { ActionTypes as _actionTypes } from './actions';
import reducer from './reducer';
import sagas from './sagas';
export type ActionTypes = _actionTypes;
export { actions, reducer, sagas };

View File

@@ -0,0 +1,38 @@
import { createReducer } from 'typesafe-actions';
import { combineReducers } from 'redux';
import { Event } from './types';
import actions, { ActionTypes } from './actions';
import {ApiError, CreateResponse} from "../../services/api-client/types";
type StateType = Readonly<{
data: Event[] | null;
loading: boolean;
failed: ApiError | null;
}>;
const initialState: StateType = {
data: [],
loading: false,
failed: null,
};
const data = createReducer<Event[], ActionTypes>(initialState.data as Event[])
.handleAction(actions.getEvents.success,(_, action) => action.payload)
.handleAction(actions.getEvents.failure, () => []);
const loading = createReducer<boolean, ActionTypes>(initialState.loading)
.handleAction(actions.getEvents.request, () => true)
.handleAction(actions.getEvents.success, () => false)
.handleAction(actions.getEvents.failure, () => false);
const failed = createReducer<ApiError | null, ActionTypes>(initialState.failed)
.handleAction(actions.getEvents.request, () => null)
.handleAction(actions.getEvents.success, () => null)
.handleAction(actions.getEvents.failure, (store, action) => action.payload);
export default combineReducers({
data,
loading,
failed,
});

23
src/store/event/sagas.ts Normal file
View File

@@ -0,0 +1,23 @@
import {all, call, put, takeLatest} from 'redux-saga/effects';
import {ApiError, ApiResponse} from '../../services/api-client/types';
import {Event} from './types'
import service from './service';
import actions from './actions';
export function* getEvents(action: ReturnType<typeof actions.getEvents.request>): Generator {
try {
const effect = yield call(service.getEvents, action.payload);
const response = effect as ApiResponse<Event[]>;
yield put(actions.getEvents.success(response.body));
} catch (err) {
yield put(actions.getEvents.failure(err as ApiError));
}
}
export default function* sagas(): Generator {
yield all([
takeLatest(actions.getEvents.request, getEvents),
]);
}

View File

@@ -0,0 +1,12 @@
import {ApiResponse, RequestPayload} from '../../services/api-client/types';
import { apiClient } from '../../services/api-client';
import {Event} from './types';
export default {
async getEvents(payload:RequestPayload<null>): Promise<ApiResponse<Event[]>> {
return apiClient.get<Event[]>(
`/api/events`,
payload
);
}
};

9
src/store/event/types.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface Event {
id: string;
timestamp: string
activity: string
activity_code: string
initiator_id: string
target_id: string
meta: { [key: string]: string }
}

View File

@@ -8,6 +8,11 @@ import { sagas as userSagas } from './user';
import { sagas as ruleSagas } from './rule';
import { sagas as groupSagas } from './group';
import { sagas as routeSagas } from './route';
import { sagas as nameserverGroupSagas } from './nameservers';
import { sagas as eventSagas } from './event';
import { sagas as dnsSettingsSagas } from './dns-settings';
import { sagas as accountSagas } from './account';
import { sagas as personalAccessTokenSagas } from './personal-access-token';
import rootReducer from './root-reducer';
import { apiClient } from '../services/api-client';
@@ -25,5 +30,10 @@ sagaMiddleware.run(userSagas);
sagaMiddleware.run(ruleSagas);
sagaMiddleware.run(groupSagas);
sagaMiddleware.run(routeSagas);
sagaMiddleware.run(nameserverGroupSagas);
sagaMiddleware.run(eventSagas);
sagaMiddleware.run(dnsSettingsSagas);
sagaMiddleware.run(accountSagas);
sagaMiddleware.run(personalAccessTokenSagas);
export { apiClient, rootReducer, store };

View File

@@ -0,0 +1,35 @@
import { ActionType, createAction, createAsyncAction } from 'typesafe-actions';
import {NameServerGroup, NameServerGroupToSave} from './types';
import {ApiError, CreateResponse, DeleteResponse, RequestPayload} from '../../services/api-client/types';
const actions = {
getNameServerGroups: createAsyncAction(
'GET_NameServerGroup_REQUEST',
'GET_NameServerGroup_SUCCESS',
'GET_NameServerGroup_FAILURE',
)<RequestPayload<null>, NameServerGroup[], ApiError>(),
saveNameServerGroup: createAsyncAction(
'SAVE_NameServerGroup_REQUEST',
'SAVE_NameServerGroup_SUCCESS',
'SAVE_NameServerGroup_FAILURE',
)<RequestPayload<NameServerGroupToSave>, CreateResponse<NameServerGroup | null>, CreateResponse<NameServerGroup | null>>(),
setSavedNameServerGroup: createAction('SET_CREATE_NameServerGroup')<CreateResponse<NameServerGroup | null>>(),
resetSavedNameServerGroup: createAction('RESET_CREATE_NameServerGroup')<null>(),
deleteNameServerGroup: createAsyncAction(
'DELETE_NameServerGroup_REQUEST',
'DELETE_NameServerGroup_SUCCESS',
'DELETE_NameServerGroup_FAILURE'
)<RequestPayload<string>, DeleteResponse<string | null>, DeleteResponse<string | null>>(),
setDeletedNameServerGroup: createAction('SET_DELETED_NameServerGroup')<DeleteResponse<string | null>>(),
resetDeletedNameServerGroup: createAction('RESET_DELETED_NameServerGroup')<null>(),
removeNameServerGroup: createAction('REMOVE_NameServerGroup')<string>(),
setNameServerGroup: createAction('SET_NameServerGroup')<NameServerGroup>(),
setSetupNewNameServerGroupVisible: createAction('SET_SETUP_NEW_NameServerGroup_VISIBLE')<boolean>(),
setSetupNewNameServerGroupHA: createAction('SET_SETUP_NEW_NameServerGroup_HA')<boolean>()
};
export type ActionTypes = ActionType<typeof actions>;
export default actions;

View File

@@ -0,0 +1,7 @@
import actions, { ActionTypes as _actionTypes } from './actions';
import reducer from './reducer';
import sagas from './sagas';
export type ActionTypes = _actionTypes;
export { actions, reducer, sagas };

View File

@@ -0,0 +1,95 @@
import { createReducer } from 'typesafe-actions';
import { combineReducers } from 'redux';
import { NameServerGroup } from './types';
import actions, { ActionTypes } from './actions';
import {ApiError, DeleteResponse, CreateResponse} from "../../services/api-client/types";
type StateType = Readonly<{
data: NameServerGroup[] | null;
nameserverGroup: NameServerGroup | null;
loading: boolean;
failed: ApiError | null;
saving: boolean;
deleteNameServerGroup: DeleteResponse<string | null>;
savedNameServerGroup: CreateResponse<NameServerGroup | null>;
setupNewNameServerGroupVisible: boolean;
setupNewNameServerGroupHA: boolean
}>;
const initialState: StateType = {
data: [],
nameserverGroup: null,
loading: false,
failed: null,
saving: false,
deleteNameServerGroup: <DeleteResponse<string | null>>{
loading: false,
success: false,
failure: false,
error: null,
data : null
},
savedNameServerGroup: <CreateResponse<NameServerGroup | null>>{
loading: false,
success: false,
failure: false,
error: null,
data : null
},
setupNewNameServerGroupVisible: false,
setupNewNameServerGroupHA: false
};
const data = createReducer<NameServerGroup[], ActionTypes>(initialState.data as NameServerGroup[])
.handleAction(actions.getNameServerGroups.success,(_, action) => action.payload)
.handleAction(actions.getNameServerGroups.failure, () => []);
const nameserverGroup = createReducer<NameServerGroup, ActionTypes>(initialState.nameserverGroup as NameServerGroup)
.handleAction(actions.setNameServerGroup, (store, action) => action.payload);
const loading = createReducer<boolean, ActionTypes>(initialState.loading)
.handleAction(actions.getNameServerGroups.request, () => true)
.handleAction(actions.getNameServerGroups.success, () => false)
.handleAction(actions.getNameServerGroups.failure, () => false);
const failed = createReducer<ApiError | null, ActionTypes>(initialState.failed)
.handleAction(actions.getNameServerGroups.request, () => null)
.handleAction(actions.getNameServerGroups.success, () => null)
.handleAction(actions.getNameServerGroups.failure, (store, action) => action.payload);
const saving = createReducer<boolean, ActionTypes>(initialState.saving)
.handleAction(actions.getNameServerGroups.request, () => true)
.handleAction(actions.getNameServerGroups.success, () => false)
.handleAction(actions.getNameServerGroups.failure, () => false);
const deletedNameServerGroup = createReducer<DeleteResponse<string | null>, ActionTypes>(initialState.deleteNameServerGroup)
.handleAction(actions.deleteNameServerGroup.request, () => initialState.deleteNameServerGroup)
.handleAction(actions.deleteNameServerGroup.success, (store, action) => action.payload)
.handleAction(actions.deleteNameServerGroup.failure, (store, action) => action.payload)
.handleAction(actions.setDeletedNameServerGroup, (store, action) => action.payload)
.handleAction(actions.resetDeletedNameServerGroup, () => initialState.deleteNameServerGroup)
const savedNameServerGroup = createReducer<CreateResponse<NameServerGroup | null>, ActionTypes>(initialState.savedNameServerGroup)
.handleAction(actions.saveNameServerGroup.request, () => initialState.savedNameServerGroup)
.handleAction(actions.saveNameServerGroup.success, (store, action) => action.payload)
.handleAction(actions.saveNameServerGroup.failure, (store, action) => action.payload)
.handleAction(actions.setSavedNameServerGroup, (store, action) => action.payload)
.handleAction(actions.resetSavedNameServerGroup, () => initialState.savedNameServerGroup)
const setupNewNameServerGroupVisible = createReducer<boolean, ActionTypes>(initialState.setupNewNameServerGroupVisible)
.handleAction(actions.setSetupNewNameServerGroupVisible, (store, action) => action.payload)
const setupNewNameServerGroupHA = createReducer<boolean, ActionTypes>(initialState.setupNewNameServerGroupHA)
.handleAction(actions.setSetupNewNameServerGroupHA, (store, action) => action.payload)
export default combineReducers({
data,
nameserverGroup,
loading,
failed,
saving,
deletedNameServerGroup,
savedNameServerGroup,
setupNewNameServerGroupVisible,
setupNewNameServerGroupHA
});

View File

@@ -0,0 +1,160 @@
import {all, call, put, select, takeLatest} from 'redux-saga/effects';
import {ApiError, ApiResponse, CreateResponse, DeleteResponse} from '../../services/api-client/types';
import {NameServerGroup} from './types'
import service from './service';
import actions from './actions';
import serviceGroup from "../group/service";
import {Group} from "../group/types";
import {actions as groupActions} from "../group";
export function* getNameServerGroups(action: ReturnType<typeof actions.getNameServerGroups.request>): Generator {
try {
yield put(actions.setDeletedNameServerGroup({
loading: false,
success: false,
failure: false,
error: null,
data: null
} as DeleteResponse<string | null>))
const effect = yield call(service.getNameServerGroups, action.payload);
const response = effect as ApiResponse<NameServerGroup[]>;
yield put(actions.getNameServerGroups.success(response.body));
} catch (err) {
yield put(actions.getNameServerGroups.failure(err as ApiError));
}
}
export function* setCreatedNameServerGroup(action: ReturnType<typeof actions.setSavedNameServerGroup>): Generator {
yield put(actions.setSavedNameServerGroup(action.payload))
}
export function* saveNameServerGroup(action: ReturnType<typeof actions.saveNameServerGroup.request>): Generator {
try {
yield put(actions.setSavedNameServerGroup({
loading: true,
success: false,
failure: false,
error: null,
data: null
} as CreateResponse<NameServerGroup | null>))
const nameserverGroupToSave = action.payload.payload
let groupsToCreate = nameserverGroupToSave.groupsToCreate
if (!groupsToCreate) {
groupsToCreate = []
}
// first, create groups that were newly added by user
const responsesGroup = yield all(groupsToCreate.map(g => call(serviceGroup.createGroup, {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: {name: g}
})
))
const resGroups = (responsesGroup as ApiResponse<Group>[]).filter(r => r.statusCode === 200).map(g => (g.body as Group)).map(g => g.id)
const newGroups = [...nameserverGroupToSave.groups, ...resGroups]
const payloadToSave = {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: {
id: nameserverGroupToSave.id,
name: nameserverGroupToSave.name,
description: nameserverGroupToSave.description,
primary: nameserverGroupToSave.primary,
domains: nameserverGroupToSave.domains,
nameservers: nameserverGroupToSave.nameservers,
groups: newGroups,
enabled: nameserverGroupToSave.enabled,
} as NameServerGroup
}
let effect
if (!nameserverGroupToSave.id) {
effect = yield call(service.createNameServerGroup, payloadToSave);
} else {
payloadToSave.payload.id = nameserverGroupToSave.id
effect = yield call(service.editNameServerGroup, payloadToSave);
}
const response = effect as ApiResponse<NameServerGroup>;
yield put(actions.saveNameServerGroup.success({
loading: false,
success: true,
failure: false,
error: null,
data: response.body
} as CreateResponse<NameServerGroup | null>));
yield put(groupActions.getGroups.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
yield put(actions.getNameServerGroups.request({ getAccessTokenSilently: action.payload.getAccessTokenSilently, payload: null }));
} catch (err) {
yield put(groupActions.getGroups.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
yield put(actions.saveNameServerGroup.failure({
loading: false,
success: false,
failure: true,
error: err as ApiError,
data: null
} as CreateResponse<NameServerGroup | null>));
}
}
export function* setDeleteNameServerGroup(action: ReturnType<typeof actions.setDeletedNameServerGroup>): Generator {
yield put(actions.setDeletedNameServerGroup(action.payload))
}
export function* deleteNameServerGroup(action: ReturnType<typeof actions.deleteNameServerGroup.request>): Generator {
try {
yield call(actions.setDeletedNameServerGroup,{
loading: true,
success: false,
failure: false,
error: null,
data: null
} as DeleteResponse<string | null>)
const effect = yield call(service.deletedNameServerGroup, action.payload);
const response = effect as ApiResponse<any>;
yield put(actions.deleteNameServerGroup.success({
loading: false,
success: true,
failure: false,
error: null,
data: response.body
} as DeleteResponse<string | null>));
const nameserverGroup = (yield select(state => state.nameserverGroup.data)) as NameServerGroup[]
yield put(actions.getNameServerGroups.success(nameserverGroup.filter((p:NameServerGroup) => p.id !== action.payload.payload)))
} catch (err) {
yield put(actions.deleteNameServerGroup.failure({
loading: false,
success: false,
failure: false,
error: err as ApiError,
data: null
} as DeleteResponse<string | null>));
}
}
export default function* sagas(): Generator {
yield all([
takeLatest(actions.getNameServerGroups.request, getNameServerGroups),
takeLatest(actions.saveNameServerGroup.request, saveNameServerGroup),
takeLatest(actions.deleteNameServerGroup.request, deleteNameServerGroup)
]);
}

View File

@@ -0,0 +1,32 @@
import {ApiResponse, RequestPayload} from '../../services/api-client/types';
import { apiClient } from '../../services/api-client';
import { NameServerGroup } from './types';
export default {
async getNameServerGroups(payload:RequestPayload<null>): Promise<ApiResponse<NameServerGroup[]>> {
return apiClient.get<NameServerGroup[]>(
`/api/dns/nameservers`,
payload
);
},
async deletedNameServerGroup(payload:RequestPayload<string>): Promise<ApiResponse<any>> {
return apiClient.delete<any>(
`/api/dns/nameservers/` + payload.payload,
payload
);
},
async createNameServerGroup(payload:RequestPayload<NameServerGroup>): Promise<ApiResponse<NameServerGroup>> {
return apiClient.post<NameServerGroup>(
`/api/dns/nameservers`,
payload
);
},
async editNameServerGroup(payload:RequestPayload<NameServerGroup>): Promise<ApiResponse<NameServerGroup>> {
const id = payload.payload.id
delete payload.payload.id
return apiClient.put<NameServerGroup>(
`/api/dns/nameservers/${id}`,
payload
);
},
};

View File

@@ -0,0 +1,21 @@
export interface NameServerGroup {
id?: string
name: string
description: string
primary: boolean
domains: string[]
nameservers: NameServer[]
groups: string[]
enabled: boolean
}
export interface NameServer {
ip: string
ns_type: string
port: number
}
export interface NameServerGroupToSave extends NameServerGroup
{
groupsToCreate: string[]
}

View File

@@ -59,7 +59,7 @@ export function* deletePeer(action: ReturnType<typeof actions.deletedPeer.reques
} as DeleteResponse<string | null>));
const peers = (yield select(state => state.peer.data)) as Peer[]
yield put(actions.getPeers.success(peers.filter((p:Peer) => p.ip !== action.payload.payload)))
yield put(actions.getPeers.success(peers.filter((p:Peer) => p.id !== action.payload.payload)))
} catch (err) {
yield put(actions.deletedPeer.failure({
loading: false,

View File

@@ -10,6 +10,18 @@ export interface Peer {
version: string,
groups?: Group[]
ssh_enabled: boolean,
hostname: string,
user_id?: string,
ui_version?: string,
dns_label: string,
last_login: string,
login_expired: boolean,
login_expiration_enabled: boolean
}
export interface FormPeer extends Peer {
groupsNames: string[],
userEmail?: string
}
export interface PeerToSave extends Peer {
@@ -30,3 +42,13 @@ export interface PeerNameToIP {
export interface PeerIPToName {
[key: string]: string;
}
export interface PeerIPToID {
[key: string]: string;
}
export interface PeerDataTable extends Peer {
key: string;
groups: Group[];
groupsCount: number;
}

View File

@@ -0,0 +1,39 @@
import { ActionType, createAction, createAsyncAction } from 'typesafe-actions';
import {PersonalAccessToken, PersonalAccessTokenCreate, PersonalAccessTokenGenerated, SpecificPAT} from './types';
import {
ApiError,
CreateResponse,
DeleteResponse,
RequestPayload
} from '../../services/api-client/types';
const actions = {
getPersonalAccessTokens: createAsyncAction(
'GET_PERSONAL_ACCESS_TOKEN_REQUEST',
'GET_PERSONAL_ACCESS_TOKEN_SUCCESS',
'GET_PERSONAL_ACCESS_TOKEN_FAILURE',
)<RequestPayload<string>, PersonalAccessToken[], ApiError>(),
savePersonalAccessToken: createAsyncAction(
'SAVE_PERSONAL_ACCESS_TOKEN_REQUEST',
'SAVE_PERSONAL_ACCESS_TOKEN_SUCCESS',
'SAVE_PERSONAL_ACCESS_TOKEN_FAILURE',
)<RequestPayload<PersonalAccessTokenCreate>, CreateResponse<PersonalAccessTokenGenerated | null>, CreateResponse<PersonalAccessTokenGenerated | null>>(),
setSavedPersonalAccessToken: createAction('SET_PERSONAL_ACCESS_TOKEN_KEY')<CreateResponse<PersonalAccessTokenGenerated | null>>(),
resetSavedPersonalAccessToken: createAction('RESET_PERSONAL_ACCESS_TOKEN_KEY')<null>(),
deletePersonalAccessToken: createAsyncAction(
'DELETE_PERSONAL_ACCESS_TOKEN_REQUEST',
'DELETE_PERSONAL_ACCESS_TOKEN_SUCCESS',
'DELETE_PERSONAL_ACCESS_TOKEN_FAILURE'
)<RequestPayload<SpecificPAT>, DeleteResponse<string | null>, DeleteResponse<string | null>>(),
setDeletePersonalAccessToken: createAction('SET_DELETE_PERSONAL_ACCESS_TOKEN')<DeleteResponse<string | null>>(),
resetDeletedPersonalAccessToken: createAction('RESET_DELETE_PERSONAL_ACCESS_TOKEN')<null>(),
removePersonalAccessToken: createAction('REMOVE_PERSONAL_ACCESS_TOKEN')<string>(),
setPersonalAccessToken: createAction('SET_SETUP_KEY')<PersonalAccessTokenCreate>(),
setNewPersonalAccessTokenVisible: createAction('SET_NEW_PERSONAL_ACCESS_TOKEN_VISIBLE')<boolean>()
};
export type ActionTypes = ActionType<typeof actions>;
export default actions;

View File

@@ -0,0 +1,7 @@
import actions, { ActionTypes as _actionTypes } from './actions';
import reducer from './reducer';
import sagas from './sagas';
export type ActionTypes = _actionTypes;
export { actions, reducer, sagas };

View File

@@ -0,0 +1,97 @@
import { createReducer } from 'typesafe-actions';
import { combineReducers } from 'redux';
import actions, { ActionTypes } from './actions';
import {ApiError, DeleteResponse, CreateResponse, ChangeResponse} from "../../services/api-client/types";
import {PersonalAccessToken, PersonalAccessTokenCreate, PersonalAccessTokenGenerated} from "./types";
type StateType = Readonly<{
data: PersonalAccessToken[] | null;
personalAccessToken: PersonalAccessTokenCreate | null;
loading: boolean;
failed: ApiError | null;
saving: boolean;
deletedPersonalAccessToken: DeleteResponse<string | null>;
revokedPersonalAccessToken: ChangeResponse<string | null>;
savedPersonalAccessToken: CreateResponse<PersonalAccessTokenGenerated | null>;
newPersonalAccessTokenVisible: boolean
}>;
const initialState: StateType = {
data: [],
personalAccessToken: null,
loading: false,
failed: null,
saving: false,
deletedPersonalAccessToken: <DeleteResponse<string | null>>{
loading: false,
success: false,
failure: false,
error: null,
data : null
},
revokedPersonalAccessToken: <ChangeResponse<string | null>>{
loading: false,
success: false,
failure: false,
error: null,
data : null
},
savedPersonalAccessToken: <CreateResponse<PersonalAccessTokenGenerated | null>>{
loading: false,
success: false,
failure: false,
error: null,
data : null
},
newPersonalAccessTokenVisible: false
};
const data = createReducer<PersonalAccessToken[], ActionTypes>(initialState.data as PersonalAccessToken[])
.handleAction(actions.getPersonalAccessTokens.success,(_, action) => action.payload)
.handleAction(actions.getPersonalAccessTokens.failure, () => []);
const personalAccessToken = createReducer<PersonalAccessTokenCreate, ActionTypes>(initialState.personalAccessToken as PersonalAccessTokenCreate)
.handleAction(actions.setPersonalAccessToken, (store, action) => action.payload);
const loading = createReducer<boolean, ActionTypes>(initialState.loading)
.handleAction(actions.getPersonalAccessTokens.request, () => true)
.handleAction(actions.getPersonalAccessTokens.success, () => false)
.handleAction(actions.getPersonalAccessTokens.failure, () => false);
const failed = createReducer<ApiError | null, ActionTypes>(initialState.failed)
.handleAction(actions.getPersonalAccessTokens.request, () => null)
.handleAction(actions.getPersonalAccessTokens.success, () => null)
.handleAction(actions.getPersonalAccessTokens.failure, (store, action) => action.payload);
const saving = createReducer<boolean, ActionTypes>(initialState.saving)
.handleAction(actions.getPersonalAccessTokens.request, () => true)
.handleAction(actions.getPersonalAccessTokens.success, () => false)
.handleAction(actions.getPersonalAccessTokens.failure, () => false);
const deletedPersonalAccessToken = createReducer<DeleteResponse<string | null>, ActionTypes>(initialState.deletedPersonalAccessToken)
.handleAction(actions.deletePersonalAccessToken.request, () => initialState.deletedPersonalAccessToken)
.handleAction(actions.deletePersonalAccessToken.success, (store, action) => action.payload)
.handleAction(actions.deletePersonalAccessToken.failure, (store, action) => action.payload)
.handleAction(actions.setDeletePersonalAccessToken, (store, action) => action.payload)
.handleAction(actions.resetDeletedPersonalAccessToken, (store, action) => initialState.deletedPersonalAccessToken);
const savedPersonalAccessToken = createReducer<CreateResponse<PersonalAccessTokenGenerated | null>, ActionTypes>(initialState.savedPersonalAccessToken)
.handleAction(actions.savePersonalAccessToken.request, () => initialState.savedPersonalAccessToken)
.handleAction(actions.savePersonalAccessToken.success, (store, action) => action.payload)
.handleAction(actions.savePersonalAccessToken.failure, (store, action) => action.payload)
.handleAction(actions.setSavedPersonalAccessToken, (store, action) => action.payload)
.handleAction(actions.resetSavedPersonalAccessToken, () => initialState.savedPersonalAccessToken)
const newPersonalAccessTokenVisible = createReducer<boolean, ActionTypes>(initialState.newPersonalAccessTokenVisible)
.handleAction(actions.setNewPersonalAccessTokenVisible, (store, action) => action.payload)
export default combineReducers({
data,
personalAccessToken: personalAccessToken,
loading,
failed,
saving,
deletedPersonalAccessToken: deletedPersonalAccessToken,
savedPersonalAccessToken: savedPersonalAccessToken,
newPersonalAccessTokenVisible: newPersonalAccessTokenVisible
});

View File

@@ -0,0 +1,101 @@
import {all, call, put, select, takeLatest} from 'redux-saga/effects';
import {ApiError, ApiResponse, CreateResponse, DeleteResponse} from '../../services/api-client/types';
import {PersonalAccessToken, PersonalAccessTokenCreate, PersonalAccessTokenGenerated} from './types'
import service from './service';
import actions from './actions';
export function* getPersonalAccessTokens(action: ReturnType<typeof actions.getPersonalAccessTokens.request>): Generator {
try {
const effect = yield call(service.getAllPersonalAccessTokens, action.payload);
const response = effect as ApiResponse<PersonalAccessToken[]>;
yield put(actions.getPersonalAccessTokens.success(response.body));
} catch (err) {
yield put(actions.getPersonalAccessTokens.failure(err as ApiError));
}
}
export function* savePersonalAccessToken(action: ReturnType<typeof actions.savePersonalAccessToken.request>): Generator {
try {
yield put(actions.setSavedPersonalAccessToken({
loading: true,
success: false,
failure: false,
error: null,
data: null
} as CreateResponse<PersonalAccessTokenGenerated | null>))
const tokenToSave = action.payload.payload
let effect = yield call(service.createPersonalAccessToken, {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: {
user_id: tokenToSave.user_id,
name: tokenToSave.name,
expires_in: tokenToSave.expires_in,
} as PersonalAccessTokenCreate
});
const response = effect as ApiResponse<PersonalAccessTokenGenerated>;
yield put(actions.savePersonalAccessToken.success({
loading: false,
success: true,
failure: false,
error: null,
data: response.body
} as CreateResponse<PersonalAccessTokenGenerated | null>));
yield put(actions.getPersonalAccessTokens.request({ getAccessTokenSilently: action.payload.getAccessTokenSilently, payload: tokenToSave.user_id }));
} catch (err) {
yield put(actions.savePersonalAccessToken.failure({
loading: false,
success: false,
failure: false,
error: err as ApiError,
data: null
} as CreateResponse<PersonalAccessTokenGenerated | null>));
}
}
export function* deletePersonalAccessToken(action: ReturnType<typeof actions.deletePersonalAccessToken.request>): Generator {
try {
yield call(actions.setDeletePersonalAccessToken,{
loading: true,
success: false,
failure: false,
error: null,
data: null
} as DeleteResponse<string | null>)
const effect = yield call(service.deletePersonalAccessToken, action.payload);
const response = effect as ApiResponse<any>;
yield put(actions.deletePersonalAccessToken.success({
loading: false,
success: true,
failure: false,
error: null,
data: response.body
} as DeleteResponse<string | null>));
const personalAccessTokens = (yield select(state => state.personalAccessToken.data)) as PersonalAccessToken[]
yield put(actions.getPersonalAccessTokens.success(personalAccessTokens.filter((p:PersonalAccessToken) => p.id !== action.payload.payload.id)))
} catch (err) {
yield put(actions.deletePersonalAccessToken.failure({
loading: false,
success: false,
failure: false,
error: err as ApiError,
data: null
} as DeleteResponse<string | null>));
}
}
export default function* sagas(): Generator {
yield all([
takeLatest(actions.getPersonalAccessTokens.request, getPersonalAccessTokens),
takeLatest(actions.savePersonalAccessToken.request, savePersonalAccessToken),
takeLatest(actions.deletePersonalAccessToken.request, deletePersonalAccessToken)
]);
}

View File

@@ -0,0 +1,34 @@
import {ApiResponse, RequestPayload} from '../../services/api-client/types';
import { apiClient } from '../../services/api-client';
import {
PersonalAccessToken,
PersonalAccessTokenCreate, PersonalAccessTokenGenerated,
SpecificPAT
} from './types';
export default {
async getAllPersonalAccessTokens(payload:RequestPayload<string>): Promise<ApiResponse<PersonalAccessToken[]>> {
return apiClient.get<PersonalAccessToken[]>(
`/api/users/` + payload.payload + `/tokens`,
payload
);
},
async getPersonalAccessToken(payload:RequestPayload<SpecificPAT>): Promise<ApiResponse<PersonalAccessToken>> {
return apiClient.get<PersonalAccessToken>(
`/api/users/` + payload.payload.user_id + `/tokens/` + payload.payload.id,
payload
);
},
async deletePersonalAccessToken(payload:RequestPayload<SpecificPAT>): Promise<ApiResponse<any>> {
return apiClient.delete<any>(
`/api/users/` + payload.payload.user_id + `/tokens/` + payload.payload.id,
payload
);
},
async createPersonalAccessToken(payload:RequestPayload<PersonalAccessTokenCreate>): Promise<ApiResponse<PersonalAccessTokenGenerated>> {
return apiClient.post<PersonalAccessTokenGenerated>(
`/api/users/` + payload.payload.user_id + `/tokens`,
payload
);
},
};

View File

@@ -0,0 +1,26 @@
export interface PersonalAccessToken {
id: string;
name: string;
expiration_date: string;
created_by: string;
created_at: string;
last_used: string;
}
export interface SpecificPAT {
name: string,
user_id: string,
id: string,
}
export interface PersonalAccessTokenGenerated {
plain_token: string,
personal_access_token: PersonalAccessToken
}
export interface PersonalAccessTokenCreate {
user_id: string,
name: string,
expires_in: number,
}

View File

@@ -1,9 +1,14 @@
import { actions as PeerActions } from './peer';
import { actions as SetupKeyActions } from './setup-key';
import { actions as UserActions } from './user';
import { actions as GroupActions } from './group';
import { actions as RuleActions } from './rule';
import { actions as RouteActions } from './route';
import {actions as PeerActions} from './peer';
import {actions as SetupKeyActions} from './setup-key';
import {actions as UserActions} from './user';
import {actions as GroupActions} from './group';
import {actions as RuleActions} from './rule';
import {actions as RouteActions} from './route';
import {actions as NameServerGroupActions} from './nameservers';
import {actions as EventActions} from './event';
import {actions as DNSSettingsActions} from './dns-settings';
import {actions as AccountActions} from './account';
import {actions as PersonalAccessTokenActions} from './personal-access-token';
export default {
peer: PeerActions,
@@ -11,5 +16,10 @@ export default {
user: UserActions,
group: GroupActions,
rule: RuleActions,
route: RouteActions
route: RouteActions,
nameserverGroup: NameServerGroupActions,
event: EventActions,
dnsSettings: DNSSettingsActions,
account: AccountActions,
personalAccessToken: PersonalAccessTokenActions
};

View File

@@ -6,6 +6,11 @@ import { reducer as user } from './user';
import { reducer as group } from './group';
import { reducer as rule } from './rule';
import { reducer as route } from './route';
import { reducer as nameserverGroup } from './nameservers';
import { reducer as event } from './event';
import { reducer as dnsSettings } from './dns-settings';
import { reducer as account } from './account';
import { reducer as personalAccessToken } from './personal-access-token';
export default combineReducers({
peer,
@@ -13,5 +18,10 @@ export default combineReducers({
user,
group,
rule,
route
route,
nameserverGroup,
event,
dnsSettings,
account,
personalAccessToken
});

View File

@@ -1,5 +1,5 @@
import { ActionType, createAction, createAsyncAction } from 'typesafe-actions';
import {Route} from './types';
import {Route, RouteToSave} from './types';
import {ApiError, CreateResponse, DeleteResponse, RequestPayload} from '../../services/api-client/types';
const actions = {
@@ -13,7 +13,7 @@ const actions = {
'SAVE_ROUTE_REQUEST',
'SAVE_ROUTE_SUCCESS',
'SAVE_ROUTE_FAILURE',
)<RequestPayload<Route>, CreateResponse<Route | null>, CreateResponse<Route | null>>(),
)<RequestPayload<RouteToSave>, CreateResponse<Route | null>, CreateResponse<Route | null>>(),
setSavedRoute: createAction('SET_CREATE_ROUTE')<CreateResponse<Route | null>>(),
resetSavedRoute: createAction('RESET_CREATE_ROUTE')<null>(),

View File

@@ -3,6 +3,9 @@ import {ApiError, ApiResponse, CreateResponse, DeleteResponse} from '../../servi
import {Route} from './types'
import service from './service';
import actions from './actions';
import serviceGroup from "../group/service";
import {Group} from "../group/types";
import {actions as groupActions} from "../group";
export function* getRoutes(action: ReturnType<typeof actions.getRoutes.request>): Generator {
try {
@@ -40,6 +43,21 @@ export function* saveRoute(action: ReturnType<typeof actions.saveRoute.request>)
const routeToSave = action.payload.payload
let groupsToCreate = routeToSave.groupsToCreate
if (!groupsToCreate) {
groupsToCreate = []
}
// first, create groups that were newly added by user
const responsesGroup = yield all(groupsToCreate.map(g => call(serviceGroup.createGroup, {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: {name: g}
})
))
const resGroups = (responsesGroup as ApiResponse<Group>[]).filter(r => r.statusCode === 200).map(g => (g.body as Group)).map(g => g.id)
const newGroups = [...routeToSave.groups, ...resGroups]
const payloadToSave = {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: {
@@ -50,7 +68,8 @@ export function* saveRoute(action: ReturnType<typeof actions.saveRoute.request>)
metric: routeToSave.metric,
network: routeToSave.network,
network_id: routeToSave.network_id,
peer: routeToSave.peer
peer: routeToSave.peer,
groups: newGroups
} as Route
}
@@ -72,8 +91,19 @@ export function* saveRoute(action: ReturnType<typeof actions.saveRoute.request>)
data: response.body
} as CreateResponse<Route | null>));
yield put(groupActions.getGroups.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
yield put(actions.getRoutes.request({ getAccessTokenSilently: action.payload.getAccessTokenSilently, payload: null }));
} catch (err) {
yield put(groupActions.getGroups.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
yield put(actions.saveRoute.failure({
loading: false,
success: false,

View File

@@ -1,3 +1,4 @@
export interface Route {
id?: string
description: string
@@ -8,4 +9,10 @@ export interface Route {
network_type?: string
metric?: number
masquerade: boolean
groups: string[]
}
export interface RouteToSave extends Route
{
groupsToCreate: string[]
}

View File

@@ -61,7 +61,9 @@ export function* saveSetupKey(action: ReturnType<typeof actions.saveSetupKey.req
payload: {
name: keyToSave.name,
auto_groups: newGroups,
type: keyToSave.type
type: keyToSave.type,
expires_in: keyToSave.expires_in,
usage_limit: keyToSave.usage_limit
} as SetupKeyToSave
});
} else {
@@ -72,6 +74,7 @@ export function* saveSetupKey(action: ReturnType<typeof actions.saveSetupKey.req
name: keyToSave.name,
revoked: keyToSave.revoked,
auto_groups: newGroups,
usage_limit: keyToSave.usage_limit
} as SetupKeyToSave
});
}

View File

@@ -1,4 +1,5 @@
import {Group} from "../group/types";
import {ExpiresInValue} from "../../views/ExpiresInInput";
import moment from "moment";
export interface SetupKey {
expires: string;
@@ -12,6 +13,15 @@ export interface SetupKey {
used_times: number;
valid: boolean;
auto_groups: string[]
expires_in: number;
usage_limit: number;
}
export interface FormSetupKey extends SetupKey {
autoGroupNames: string[]
expiresInFormatted: ExpiresInValue
exp: moment.Moment
last: moment.Moment
}
export interface SetupKeyToSave extends SetupKey

View File

@@ -1,13 +1,26 @@
import { ActionType, createAsyncAction } from 'typesafe-actions';
import { User } from './types';
import { ApiError, RequestPayload } from '../../services/api-client/types';
import {ActionType, createAction, createAsyncAction} from 'typesafe-actions';
import {User, UserToSave} from './types';
import {ApiError, CreateResponse, RequestPayload} from '../../services/api-client/types';
const actions = {
getUsers: createAsyncAction(
'GET_USERS_REQUEST',
'GET_USERS_SUCCESS',
'GET_USERS_FAILURE',
)<RequestPayload<null>, User[], ApiError>()
)<RequestPayload<null>, User[], ApiError>(),
// used to set a user object that was picked in the user table in the UserUpdate drawer (user update window on right-side).
setUser: createAction('SET_USER')<User>(),
// used to make the UserUpdate drawer visible in the UI.
setUpdateUserDrawerVisible: createAction('SET_UPDATE_USER_VISIBLE')<boolean>(),
saveUser: createAsyncAction(
'SAVE_USER_REQUEST',
'SAVE_USER_SUCCESS',
'SAVE_USER_FAILURE',
)<RequestPayload<UserToSave>, CreateResponse<User | null>, CreateResponse<User | null>>(),
setSavedUser: createAction('SET_SAVED_USER')<CreateResponse<User | null>>(),
resetSavedUser: createAction('RESET_SAVED_USER')<null>(),
};
export type ActionTypes = ActionType<typeof actions>;

View File

@@ -2,18 +2,31 @@ import { createReducer } from 'typesafe-actions';
import { combineReducers } from 'redux';
import { User } from './types';
import actions, { ActionTypes } from './actions';
import { ApiError } from "../../services/api-client/types";
import {ApiError, CreateResponse} from "../../services/api-client/types";
type StateType = Readonly<{
data: User[] | null;
loading: boolean;
failed: ApiError | null;
user: User | null;
savedUser: CreateResponse<User | null>;
updateUserDrawerVisible: boolean
}>;
const initialState: StateType = {
data: [],
loading: false,
failed: null,
user: null,
// right-sided user update drawer
updateUserDrawerVisible: false,
savedUser: <CreateResponse<User | null>>{
loading: false,
success: false,
failure: false,
error: null,
data : null
},
};
const data = createReducer<User[], ActionTypes>(initialState.data as User[])
@@ -30,9 +43,23 @@ const failed = createReducer<ApiError | null, ActionTypes>(initialState.failed)
.handleAction(actions.getUsers.success, () => null)
.handleAction(actions.getUsers.failure, (store, action) => action.payload);
const user = createReducer<User, ActionTypes>(initialState.user as User)
.handleAction(actions.setUser, (store, action) => action.payload);
const updateUserDrawerVisible = createReducer<boolean, ActionTypes>(initialState.updateUserDrawerVisible)
.handleAction(actions.setUpdateUserDrawerVisible, (store, action) => action.payload);
const savedUser = createReducer<CreateResponse<User | null>, ActionTypes>(initialState.savedUser)
.handleAction(actions.saveUser.request, () => initialState.savedUser)
.handleAction(actions.saveUser.success, (store, action) => action.payload)
.handleAction(actions.saveUser.failure, (store, action) => action.payload)
.handleAction(actions.setSavedUser, (store, action) => action.payload)
.handleAction(actions.resetSavedUser, () => initialState.savedUser)
export default combineReducers({
data,
loading,
failed
failed,
user,
savedUser,
updateUserDrawerVisible
});

View File

@@ -1,23 +1,103 @@
import {all, call, put, takeLatest} from 'redux-saga/effects';
import {ApiError, ApiResponse} from '../../services/api-client/types';
import { User } from './types'
import {ApiError, ApiResponse, CreateResponse} from '../../services/api-client/types';
import {User, UserToSave} from './types'
import service from './service';
import actions from './actions';
import serviceGroup from "../group/service";
import {Group} from "../group/types";
import {actions as groupActions} from "../group";
export function* getPeers(action: ReturnType<typeof actions.getUsers.request>): Generator {
try {
const effect = yield call(service.getUsers, action.payload);
const response = effect as ApiResponse<User[]>;
export function* getUsers(action: ReturnType<typeof actions.getUsers.request>): Generator {
try {
const effect = yield call(service.getUsers, action.payload);
const response = effect as ApiResponse<User[]>;
yield put(actions.getUsers.success(response.body));
} catch (err) {
yield put(actions.getUsers.failure(err as ApiError));
}
yield put(actions.getUsers.success(response.body));
} catch (err) {
yield put(actions.getUsers.failure(err as ApiError));
}
}
export function* saveUser(action: ReturnType<typeof actions.saveUser.request>): Generator {
try {
yield put(actions.setSavedUser({
loading: true,
success: false,
failure: false,
error: null,
data: null
} as CreateResponse<User | null>))
const userToSave = action.payload.payload
let groupsToCreate = userToSave.groupsToCreate
if (!groupsToCreate) {
groupsToCreate = []
}
// first, create groups that were newly added by user
const responsesGroup = yield all(groupsToCreate.map(g => call(serviceGroup.createGroup, {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: {name: g}
})
))
const resGroups = (responsesGroup as ApiResponse<Group>[]).filter(r => r.statusCode === 200).map(g => (g.body as Group)).map(g => g.id)
const newGroups = [...userToSave.auto_groups, ...resGroups]
let payload = {
name: userToSave.name,
email: userToSave.email,
role: userToSave.role,
auto_groups: newGroups,
} as UserToSave
let effect
if (!userToSave.id) {
console.log("creating user:" + payload)
effect = yield call(service.createUser, {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: payload
});
} else {
payload.id = userToSave.id
effect = yield call(service.editUser, {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: payload
});
}
const response = effect as ApiResponse<User>;
yield put(actions.saveUser.success({
loading: false,
success: true,
failure: false,
error: null,
data: response.body
} as CreateResponse<User | null>));
yield put(groupActions.getGroups.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
yield put(actions.getUsers.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
} catch (err) {
yield put(actions.saveUser.failure({
loading: false,
success: false,
failure: false,
error: err as ApiError,
data: null
} as CreateResponse<User | null>));
}
}
export default function* sagas(): Generator {
yield all([
takeLatest(actions.getUsers.request, getPeers)
]);
yield all([
takeLatest(actions.getUsers.request, getUsers),
takeLatest(actions.saveUser.request, saveUser)
]);
}

View File

@@ -1,6 +1,6 @@
import {ApiResponse, RequestPayload} from '../../services/api-client/types';
import { apiClient } from '../../services/api-client';
import { User } from './types';
import {User, UserToSave} from './types';
export default {
async getUsers(payload:RequestPayload<null>): Promise<ApiResponse<User[]>> {
@@ -8,5 +8,21 @@ export default {
`/api/users`,
payload
);
}
},
async editUser(payload:RequestPayload<UserToSave>): Promise<ApiResponse<User>> {
const id = payload.payload.id
// @ts-ignore
delete payload.payload.id
return apiClient.put<User>(
`/api/users/${id}`,
payload
);
},
async createUser(payload:RequestPayload<UserToSave>): Promise<ApiResponse<User>> {
// @ts-ignore
return apiClient.post<User>(
`/api/users`,
payload
);
},
};

View File

@@ -3,4 +3,15 @@ export interface User {
email?: string;
name: string;
role: string;
}
status: string;
auto_groups: string[];
is_current?: boolean;
}
export interface FormUser extends User {
autoGroupsNames: string[]
}
export interface UserToSave extends User {
groupsToCreate: string[]
}

View File

@@ -1,3 +1,5 @@
export const formatOS = (os) => {
if (os.startsWith("windows 10")) {
return "Windows 10";
@@ -6,14 +8,39 @@ export const formatOS = (os) => {
if (os.startsWith("Darwin")) {
return os.replace("Darwin", "MacOS");
}
// capitalize first letter
os = os.charAt(0).toUpperCase() + os.slice(1);
return os;
};
export const formatDate = date => {
if (new Date(date).getTime() > new Date("2099-12-31").getTime()) {
return new Date(date).toLocaleDateString("en-GB", { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' });
}
return new Date(date).toLocaleDateString("en-GB", { weekday: 'short', year: '2-digit', month: 'short', day: 'numeric' });
}
export const capitalize = text => {
if (!text) {
return text
}
return text.charAt(0).toUpperCase() + text.slice(1)
}
export const checkExpiresIn = (_, value) => {
if (value.number > 0) {
return Promise.resolve();
}
return Promise.reject(new Error("Expiration must be greater than zero"));
};
export const formatDateTime = date => {
if (new Date(date).getTime() > new Date("2099-12-31").getTime()) {
return new Date(date).toLocaleDateString("en-GB", { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' });
}
return new Date(date).toLocaleDateString("en-GB", { weekday: 'short', year: '2-digit', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' });
}
export const classNames = (...classes) => {
return classes.filter(Boolean).join(' ')
}
@@ -66,7 +93,9 @@ export const timeAgo = (dateParam) => {
const isThisYear = today.getFullYear() === date.getFullYear();
if (seconds < 5) {
if (seconds < -1) {
return getFormattedDate(date, false, true);
} else if (seconds < 5) {
return 'just now';
} else if (seconds < 60) {
return `${ seconds } seconds ago`;
@@ -75,9 +104,9 @@ export const timeAgo = (dateParam) => {
} else if (minutes < 60) {
return `${ minutes } minutes ago`;
} else if (isToday) {
return getFormattedDate(date, 'Today'); // Today at 10:20
return getFormattedDate(date, 'today'); // Today at 10:20
} else if (isYesterday) {
return getFormattedDate(date, 'Yesterday'); // Yesterday at 10:20
return getFormattedDate(date, 'yesterday'); // Yesterday at 10:20
} else if (isThisYear) {
return getFormattedDate(date, false, true); // 10. January at 10:20
}
@@ -87,4 +116,12 @@ export const timeAgo = (dateParam) => {
export const copyToClipboard = (copyText) => {
navigator.clipboard.writeText(copyText);
}
export const isNetBirdHosted = () => {
return window.location.hostname.endsWith(".netbird.io") || window.location.hostname.endsWith(".wiretrustee.com")
}
export const isLocalDev = () => {
return window.location.hostname.includes("localhost")
}

141
src/utils/groups.tsx Normal file
View File

@@ -0,0 +1,141 @@
import {CustomTagProps} from "rc-select/lib/BaseSelect";
import React, {useEffect, useState} from "react";
import {Col, Divider, Row, Tag} from "antd";
import {useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {RuleObject} from "antd/lib/form";
export const useGetGroupTagHelpers = () => {
const groups = useSelector((state: RootState) => state.group.data)
const [tagGroups, setTagGroups] = useState([] as string[])
const [groupTagFilterAll, setGroupTagFilterAll] = useState(false)
const [selectedTagGroups, setSelectedTagGroups] = useState([] as string[])
const tagRender = (props: CustomTagProps) => {
const {value, closable, onClose} = props;
const onPreventMouseDown = (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
};
return (
<Tag
color="blue"
onMouseDown={onPreventMouseDown}
closable={closable}
onClose={onClose}
style={{marginRight: 3}}
>
<strong>{value}</strong>
</Tag>
);
}
const handleChangeTags = (value: string[]) => {
let validatedValues: string[] = []
value.forEach(function (v) {
if (v.trim().length) {
validatedValues.push(v)
}
})
setSelectedTagGroups(validatedValues)
};
const dropDownRender = (menu: React.ReactElement) => (
<>
{menu}
<Divider style={{margin: '8px 0'}}/>
<Row style={{padding: '0 8px 4px'}}>
<Col flex="auto">
<span style={{color: "#9CA3AF"}}>Add new group by pressing "Enter"</span>
</Col>
<Col flex="none">
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.70455 7.19176V5.89915H10.3949C10.7727 5.89915 11.1174 5.80634 11.429 5.62074C11.7405 5.43513 11.9875 5.18655 12.1697 4.875C12.3554 4.56345 12.4482 4.21875 12.4482 3.84091C12.4482 3.46307 12.3554 3.12003 12.1697 2.81179C11.9841 2.50024 11.7356 2.25166 11.424 2.06605C11.1158 1.88044 10.7727 1.78764 10.3949 1.78764H9.83807V0.5H10.3949C11.0114 0.5 11.5715 0.650805 12.0753 0.952414C12.5791 1.25402 12.9818 1.65672 13.2834 2.16051C13.585 2.6643 13.7358 3.22443 13.7358 3.84091C13.7358 4.30161 13.648 4.73414 13.4723 5.13849C13.3 5.54285 13.0613 5.89915 12.7564 6.20739C12.4515 6.51562 12.0968 6.75758 11.6925 6.93324C11.2881 7.10559 10.8556 7.19176 10.3949 7.19176H1.70455ZM4.90128 11.0646L0.382102 6.54545L4.90128 2.02628L5.79119 2.91619L2.15696 6.54545L5.79119 10.1747L4.90128 11.0646Z"
fill="#9CA3AF"/>
</svg>
</Col>
</Row>
</>
)
const optionRender = (label: string) => {
let peersCount = ''
const g = groups.find(_g => _g.name === label)
if (g) peersCount = ` - ${g.peers_count || 0} ${(!g.peers_count || parseInt(g.peers_count) !== 1) ? 'peers' : 'peer'} `
return (
<>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{label}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</>
)
}
const getExistingAndToCreateGroupsLists = (groupNameList: string[]): [string[], string[]] => {
const groupIDList = groups?.filter(g => groupNameList.includes(g.name)).map(g => g.id || '') || []
// find groups that do not yet exist (newly added by the user)
const existingGroupsNames: string[] = groups?.map(g => g.name);
const groupNameListToCreate = groupNameList.filter(s => !existingGroupsNames.includes(s))
return [groupIDList, groupNameListToCreate]
}
const getGroupNamesFromIDs = (groupIDList: string[]): string[] => {
if (!groupIDList) {
return []
}
return groups?.filter(g => groupIDList.includes(g.id!)).map(g => g.name || '') || []
}
const selectValidator = (obj: RuleObject, value: string[]) => {
if (!value.length) {
return Promise.reject(new Error("Please enter at least one group"))
}
return selectValidatorEmptyStrings(obj,value)
}
const selectValidatorEmptyStrings = (_: RuleObject, value: string[]) => {
let hasSpaceNamed = []
value.forEach(function (v: string) {
if (!v.trim().length) {
hasSpaceNamed.push(v)
}
})
if (hasSpaceNamed.length) {
return Promise.reject(new Error("Group names with just spaces are not allowed"))
}
return Promise.resolve()
}
useEffect(() => {
if (groupTagFilterAll) {
setTagGroups(groups?.filter(g => g.name != "All").map(g => g.name) || [])
} else {
setTagGroups(groups?.map(g => g.name) || [])
}
}, [groups])
return {
tagRender,
handleChangeTags,
dropDownRender,
optionRender,
tagGroups,
selectedTagGroups,
setGroupTagFilterAll,
getExistingAndToCreateGroupsLists,
getGroupNamesFromIDs,
selectValidator,
selectValidatorEmptyStrings
}
}

24
src/utils/pageSize.tsx Normal file
View File

@@ -0,0 +1,24 @@
import {useState} from "react";
export const usePageSizeHelpers = () => {
const [pageSize, setPageSize] = useState(10);
const onChangePageSize = (value: string) => {
setPageSize(parseInt(value.toString()))
}
const pageSizeOptions = [
{label: "10", value: "10"},
{label: "25", value: "25"},
{label: "50", value: "50"},
{label: "100", value: "100"},
{label: "1000", value: "1000"}
]
return {
onChangePageSize,
pageSize,
pageSizeOptions
}
}

View File

@@ -1,4 +1,4 @@
import {Peer, PeerNameToIP, PeerIPToName} from "../store/peer/types";
import {Peer, PeerIPToID, PeerIPToName, PeerNameToIP} from "../store/peer/types";
import {Route} from "../store/route/types";
export const routePeerSeparator = " - "
@@ -7,22 +7,26 @@ export const masqueradeDisabledMSG = "Enabling this option hides other NetBird n
export const masqueradeEnabledMSG = "Disabling this option stops hiding all traffic coming from other NetBird peers behind the routing peer local address when accessing the target Network CIDR. You will need to configure routes for your NetBird network pointing to your routing peer on your local routers or other devices."
export const peerToPeerIP = (name:string,ip:string):string => {
export const peerToPeerIP = (name: string, ip: string): string => {
return name + routePeerSeparator + ip
}
export const initPeerMaps = (peers:Peer[]): [PeerNameToIP, PeerIPToName] => {
export const initPeerMaps = (peers: Peer[]): [PeerNameToIP, PeerIPToName, PeerIPToID] => {
let peerNameToIP = {} as PeerNameToIP
let peerIPToName = {} as PeerIPToName
peers.forEach((p) =>{
let peerIPToID = {} as PeerIPToID
peers.forEach((p) => {
peerNameToIP[p.name] = p.ip
peerIPToName[p.ip] = p.name
peerIPToID[p.ip] = p.id ? p.id : ""
})
return [ peerNameToIP, peerIPToName]
return [peerNameToIP, peerIPToName, peerIPToID]
}
export interface RouteDataTable extends Route {
key: string;
peer_ip: string;
peer_name: string;
}
export interface GroupedDataTable {
@@ -34,38 +38,47 @@ export interface GroupedDataTable {
description: string
routesCount: number
groupedRoutes: RouteDataTable[]
routesGroups: string[]
}
export const transformDataTable = (d:Route[],peerIPToName:PeerIPToName):RouteDataTable[] => {
return d.map(p => {
export const transformDataTable = (routes: Route[], peers: Peer[]): RouteDataTable[] => {
let peerMap = Object.fromEntries(peers.map(p => [p.id, p]));
return routes.map(route => {
return {
key: p.id,
...p,
peer: peerIPToName[p.peer] ? peerIPToName[p.peer] : p.peer,
key: route.id,
...route,
peer: route.peer,
peer_ip: peerMap[route.peer] ? peerMap[route.peer].ip : route.peer,
peer_name: peerMap[route.peer] ? peerMap[route.peer].name : route.peer,
} as RouteDataTable
})
}
export const transformGroupedDataTable = (routes:Route[],peerIPToName:PeerIPToName):GroupedDataTable[] => {
export const transformGroupedDataTable = (routes: Route[], peers: Peer[]): GroupedDataTable[] => {
let keySet = new Set(routes.map(r => {
return r.network_id + r.network
}))
let groupedRoutes:GroupedDataTable[] = []
let groupedRoutes: GroupedDataTable[] = []
keySet.forEach((p) => {
let hasEnabled = false
let lastRoute:Route
let listedRoutes:Route[] = []
let lastRoute: Route
let listedRoutes: Route[] = []
let groupList: string[] = []
routes.forEach((r) => {
if ( p === r.network_id + r.network ) {
if (p === r.network_id + r.network) {
lastRoute = r
if (r.enabled) {
hasEnabled = true
}
listedRoutes.push(r)
groupList = groupList.concat(r.groups)
}
})
let groupDataTableRoutes = transformDataTable(listedRoutes,peerIPToName)
groupList = groupList.filter((value, index, arrary) => arrary.indexOf(value) === index)
let groupDataTableRoutes = transformDataTable(listedRoutes, peers)
groupedRoutes.push({
key: p.toString(),
network_id: lastRoute!.network_id,
@@ -75,6 +88,7 @@ export const transformGroupedDataTable = (routes:Route[],peerIPToName:PeerIPToNa
enabled: hasEnabled,
routesCount: groupDataTableRoutes.length,
groupedRoutes: groupDataTableRoutes,
routesGroups: groupList,
})
})
return groupedRoutes

71
src/utils/token.ts Normal file
View File

@@ -0,0 +1,71 @@
import {useOidcAccessToken, useOidcIdToken} from "@axa-fr/react-oidc";
import {createRef, useEffect} from "react";
function sleep(ms : number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function b64DecodeUnicode(str: string): string {
// See https://www.rfc-editor.org/rfc/rfc7515.txt, Appendix C
str = str.replace('-', '+');
str = str.replace('_', '/');
switch (str.length % 4) {
case 0: break;
case 2: str += '=='; break;
case 3: str += '='; break;
}
return decodeURIComponent(Array.prototype.map.call(atob(str), (c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join(''));
}
function parseJwt(token:string) {
return JSON.parse(b64DecodeUnicode(token.split('.')[1].replace('-', '+').replace('_', '/')))
}
function isTokenValid(token:string) {
let tokenPayload = parseJwt(token)
return tokenPayload && (tokenPayload.exp * 1000) > (Date.now() - 5000)
}
// latestToken as global allows for use of the latest state value across calls
let latestToken:string
// hook that returns a getAccessTokenSilently function that returns an access token promise,
// waiting for renewal if it was expired
export const useGetTokenSilently = () => {
const getTokenSilently = async (): Promise<string> => {
let attempt = 0
while (!isTokenValid(latestToken) && attempt < 15){
attempt++
await sleep(500)
}
return latestToken
};
return {getTokenSilently}
}
export const useTokenSource = (source:string) => {
const {idToken} = useOidcIdToken()
const {accessToken} = useOidcAccessToken()
if (source.toLowerCase() == "idtoken") {
latestToken = idToken
} else {
latestToken = accessToken
}
useEffect(() => {
// defaults to access token(current token) if no id token was specified
if (source.toLowerCase() != "idtoken") {
latestToken = accessToken
}
}, [accessToken])
useEffect(() => {
if (source.toLowerCase() == "idtoken") {
latestToken = idToken
}
}, [idToken])
}

View File

@@ -1,9 +1,23 @@
import React, {useEffect, useState} from 'react';
import {
Alert,
Button, Card,
Col, Dropdown, Input, Menu, message, Modal, Popover, Radio, RadioChangeEvent,
Row, Select, Space, Table, Tag, Tooltip,
Button,
Card,
Col,
Dropdown,
Input,
Menu,
message,
Modal,
Popover,
Radio,
RadioChangeEvent,
Row,
Select,
Space,
Table,
Tag,
Tooltip,
Typography
} from "antd";
import {Container} from "../components/Container";
@@ -21,10 +35,12 @@ import AccessControlNew from "../components/AccessControlNew";
import {Group} from "../store/group/types";
import AccessControlModalGroups from "../components/AccessControlModalGroups";
import tableSpin from "../components/Spin";
import {useOidcAccessToken} from '@axa-fr/react-oidc';
const { Title, Paragraph } = Typography;
const { Column } = Table;
const { confirm } = Modal;
import {useGetTokenSilently} from "../utils/token";
import {usePageSizeHelpers} from "../utils/pageSize";
const {Title, Paragraph, Text} = Typography;
const {Column} = Table;
const {confirm} = Modal;
interface RuleDataTable extends Rule {
key: string;
@@ -41,7 +57,8 @@ interface GroupsToShow {
}
export const AccessControl = () => {
const {accessToken} = useOidcAccessToken()
const {onChangePageSize,pageSizeOptions,pageSize} = usePageSizeHelpers()
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const rules = useSelector((state: RootState) => state.rule.data);
@@ -53,19 +70,15 @@ export const AccessControl = () => {
const [showTutorial, setShowTutorial] = useState(true)
const [textToSearch, setTextToSearch] = useState('');
const [optionAllEnable, setOptionAllEnable] = useState('enabled');
const [pageSize, setPageSize] = useState(5);
const [currentPage, setCurrentPage] = useState(1);
const [dataTable, setDataTable] = useState([] as RuleDataTable[]);
const [ruleToAction, setRuleToAction] = useState(null as RuleDataTable | null);
const [groupsToShow, setGroupsToShow] = useState({} as GroupsToShow)
const setupNewRuleVisible = useSelector((state: RootState) => state.rule.setupNewRuleVisible);
const [groupPopupVisible, setGroupPopupVisible] = useState(false as boolean | undefined)
const pageSizeOptions = [
{label: "5", value: "5"},
{label: "10", value: "10"},
{label: "15", value: "15"}
]
const optionsAllEnabled = [{label: 'Enabled', value: 'enabled'},{label: 'All', value: 'all'}]
const optionsAllEnabled = [{label: 'Enabled', value: 'enabled'}, {label: 'All', value: 'all'}]
const itemsMenuAction = [
{
@@ -81,17 +94,17 @@ export const AccessControl = () => {
label: (<Button type="text" block onClick={() => showConfirmDelete()}>Delete</Button>)
}
]
const actionsMenu = (<Menu items={itemsMenuAction} ></Menu>)
const actionsMenu = (<Menu items={itemsMenuAction}></Menu>)
const getSourceDestinationLabel = (data:Group[]):string => {
const getSourceDestinationLabel = (data: Group[]): string => {
return (!data) ? "No group" : (data.length > 1) ? `${data.length} Groups` : (data.length === 1) ? data[0].name : "No group"
}
const isShowTutorial = (rules:Rule[]):boolean => {
const isShowTutorial = (rules: Rule[]): boolean => {
return (!rules.length || (rules.length === 1 && rules[0].name === "Default"))
}
const transformDataTable = (d:Rule[]):RuleDataTable[] => {
const transformDataTable = (d: Rule[]): RuleDataTable[] => {
return d.map(p => {
const sourceLabel = getSourceDestinationLabel(p.sources as Group[])
const destinationLabel = getSourceDestinationLabel(p.destinations as Group[])
@@ -106,47 +119,66 @@ export const AccessControl = () => {
}
useEffect(() => {
dispatch(ruleActions.getRules.request({getAccessTokenSilently:accessToken, payload: null}));
dispatch(groupActions.getGroups.request({getAccessTokenSilently:accessToken, payload: null}));
dispatch(ruleActions.getRules.request({getAccessTokenSilently: getTokenSilently, payload: null}));
dispatch(groupActions.getGroups.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}, [])
useEffect(() => {
setShowTutorial(isShowTutorial(rules))
setDataTable(sortBy(transformDataTable(filterDataTable()), "name"))
if (failed) {
setShowTutorial(false)
} else {
setShowTutorial(isShowTutorial(rules))
setDataTable(sortBy(transformDataTable(filterDataTable()), "name"))
}
}, [rules])
useEffect(() => {
setDataTable(transformDataTable(filterDataTable()))
}, [textToSearch, optionAllEnable])
const styleNotification = { marginTop: 85 }
const styleNotification = {marginTop: 85}
const saveKey = 'saving';
useEffect(() => {
if (savedRule.loading) {
message.loading({ content: 'Saving...', key: saveKey, duration: 0, style: styleNotification })
message.loading({content: 'Saving...', key: saveKey, duration: 0, style: styleNotification})
} else if (savedRule.success) {
message.success({ content: 'Rule has been successfully saved.', key: saveKey, duration: 2, style: styleNotification });
message.success({
content: 'Rule has been successfully saved.',
key: saveKey,
duration: 2,
style: styleNotification
});
dispatch(ruleActions.setSetupNewRuleVisible(false))
dispatch(ruleActions.setSavedRule({ ...savedRule, success: false }))
dispatch(ruleActions.setSavedRule({...savedRule, success: false}))
dispatch(ruleActions.resetSavedRule(null))
} else if (savedRule.error) {
message.error({ content: 'Failed to update rule. You might not have enough permissions.', key: saveKey, duration: 2, style: styleNotification });
dispatch(ruleActions.setSavedRule({ ...savedRule, error: null }))
message.error({
content: 'Failed to update rule. You might not have enough permissions.',
key: saveKey,
duration: 2,
style: styleNotification
});
dispatch(ruleActions.setSavedRule({...savedRule, error: null}))
dispatch(ruleActions.resetSavedRule(null))
}
}, [savedRule])
const deleteKey = 'deleting';
useEffect(() => {
const style = { marginTop: 85 }
const style = {marginTop: 85}
if (deletedRule.loading) {
message.loading({ content: 'Deleting...', key: deleteKey, style })
message.loading({content: 'Deleting...', key: deleteKey, style})
} else if (deletedRule.success) {
message.success({ content: 'Rule has been successfully disabled.', key: deleteKey, duration: 2, style })
message.success({content: 'Rule has been successfully disabled.', key: deleteKey, duration: 2, style})
dispatch(ruleActions.resetDeletedRule(null))
} else if (deletedRule.error) {
message.error({ content: 'Failed to remove rule. You might not have enough permissions.', key: deleteKey, duration: 2, style })
message.error({
content: 'Failed to remove rule. You might not have enough permissions.',
key: deleteKey,
duration: 2,
style
})
dispatch(ruleActions.resetDeletedRule(null))
}
}, [deletedRule])
@@ -160,29 +192,25 @@ export const AccessControl = () => {
setDataTable(transformDataTable(data))
}
const onChangeAllEnabled = ({ target: { value } }: RadioChangeEvent) => {
const onChangeAllEnabled = ({target: {value}}: RadioChangeEvent) => {
setOptionAllEnable(value)
}
const onChangePageSize = (value: string) => {
setPageSize(parseInt(value.toString()))
}
const showConfirmDelete = () => {
let name = ruleToAction ? ruleToAction.name : '';
confirm({
icon: <ExclamationCircleOutlined />,
icon: <ExclamationCircleOutlined/>,
title: "Delete rule \"" + name + "\"",
width: 600,
content: <Space direction="vertical" size="small">
{ruleToAction &&
<>
<Title level={5}>Delete rule "{ruleToAction ? ruleToAction.name : ''}"</Title>
<Paragraph>Are you sure you want to delete peer from your account?</Paragraph>
</>
}
<Paragraph>Are you sure you want to delete this rule from your account?</Paragraph>
</Space>,
okType: 'danger',
onOk() {
dispatch(ruleActions.deleteRule.request({getAccessTokenSilently:accessToken, payload: ruleToAction?.id || ''}));
dispatch(ruleActions.deleteRule.request({
getAccessTokenSilently: getTokenSilently,
payload: ruleToAction?.id || ''
}));
},
onCancel() {
setRuleToAction(null);
@@ -192,7 +220,7 @@ export const AccessControl = () => {
const showConfirmDeactivate = () => {
confirm({
icon: <ExclamationCircleOutlined />,
icon: <ExclamationCircleOutlined/>,
width: 600,
content: <Space direction="vertical" size="small">
{ruleToAction &&
@@ -212,13 +240,13 @@ export const AccessControl = () => {
});
}
const filterDataTable = ():Rule[] => {
const filterDataTable = (): Rule[] => {
const t = textToSearch.toLowerCase().trim()
let f:Rule[] = filter(rules, (f:Rule) =>
let f: Rule[] = filter(rules, (f: Rule) =>
(f.name.toLowerCase().includes(t) || f.description.toLowerCase().includes(t) || t === "")
) as Rule[]
if (optionAllEnable !== "all") {
f = filter(f, (f:Rule) => !f.disabled)
f = filter(f, (f: Rule) => !f.disabled)
}
return f
}
@@ -261,7 +289,7 @@ export const AccessControl = () => {
} as Rule))
}
const toggleModalGroups = (title:string, groups:Group[] | string[] | null, modalVisible:boolean) => {
const toggleModalGroups = (title: string, groups: Group[] | string[] | null, modalVisible: boolean) => {
setGroupsToShow({
title,
groups,
@@ -269,40 +297,60 @@ export const AccessControl = () => {
})
}
const renderPopoverGroups = (label: string, groups:Group[] | string[] | null, rule: RuleDataTable) => {
useEffect(() => {
if (setupNewRuleVisible) {
setGroupPopupVisible(false)
}
}, [setupNewRuleVisible])
const onPopoverVisibleChange = (b: boolean) => {
if (setupNewRuleVisible) {
setGroupPopupVisible(false)
} else {
setGroupPopupVisible(undefined)
}
}
const renderPopoverGroups = (label: string, groups: Group[] | string[] | null, rule: RuleDataTable) => {
const content = groups?.map((g, i) => {
const _g = g as Group
const peersCount = ` - ${_g.peers_count || 0} ${(!_g.peers_count || parseInt(_g.peers_count) !== 1) ? 'peers' : 'peer'} `
return (
<div key={i}>
<Tag
color="blue"
style={{ marginRight: 3 }}
>
<strong>{_g.name}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</div>
<div key={i}>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{_g.name}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</div>
)
})
const mainContent = (<Space direction="vertical">{content}</Space>)
return (
<Popover content={<Space direction="vertical">{content}</Space>} title={null}>
<Popover
onOpenChange={onPopoverVisibleChange}
open={groupPopupVisible}
content={mainContent}
title={null}>
<Button type="link" onClick={() => setRuleAndView(rule)}>{label}</Button>
</Popover>
)
}
return(
return (
<>
<Container className="container-main">
<Row>
<Col span={24}>
<Title level={4}>Access Control</Title>
<Paragraph>Access rules help you manage access permissions in your organisation.</Paragraph>
<Space direction="vertical" size="large" style={{ display: 'flex' }}>
<Space direction="vertical" size="large" style={{display: 'flex'}}>
<Row gutter={[16, 24]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} xxl={8} span={8}>
<Input allowClear value={textToSearch} onPressEnter={searchDataTable} placeholder="Search..." onChange={onChangeTextToSearch} />
<Input allowClear value={textToSearch} onPressEnter={searchDataTable}
placeholder="Search..." onChange={onChangeTextToSearch}/>
</Col>
<Col xs={24} sm={24} md={11} lg={11} xl={11} xxl={11} span={11}>
<Space size="middle">
@@ -313,7 +361,8 @@ export const AccessControl = () => {
optionType="button"
buttonStyle="solid"
/>
<Select value={pageSize.toString()} options={pageSizeOptions} onChange={onChangePageSize} className="select-rows-per-page-en"/>
<Select value={pageSize.toString()} options={pageSizeOptions}
onChange={onChangePageSize} className="select-rows-per-page-en"/>
</Space>
</Col>
<Col xs={24}
@@ -324,13 +373,15 @@ export const AccessControl = () => {
xxl={5} span={5}>
<Row justify="end">
<Col>
<Button type="primary" disabled={savedRule.loading} onClick={onClickAddNewRule}>Add Rule</Button>
<Button type="primary" disabled={savedRule.loading}
onClick={onClickAddNewRule}>Add Rule</Button>
</Col>
</Row>
</Col>
</Row>
{failed &&
<Alert message={failed.code} description={failed.message} type="error" showIcon closable/>
<Alert message={failed.message} description={failed.data ? failed.data.message : " "} type="error" showIcon
closable/>
}
<Card bodyStyle={{padding: 0}}>
<Table
@@ -353,24 +404,27 @@ export const AccessControl = () => {
defaultSortOrder='ascend'
render={(text, record, index) => {
const desc = (record as RuleDataTable).description.trim()
return <Tooltip title={desc !== "" ? desc : "no description"} arrowPointAtCenter>
<span onClick={() => setRuleAndView(record as RuleDataTable)} className="tooltip-label">{text}</span>
return <Tooltip title={desc !== "" ? desc : "no description"}
arrowPointAtCenter>
<span onClick={() => setRuleAndView(record as RuleDataTable)}
className="tooltip-label"><Text strong>{text}</Text></span>
</Tooltip>
}}
/>
<Column title="Status" dataIndex="disabled"
render={(text:Boolean, record:RuleDataTable, index) => {
return text ? <Tag color="red">disabled</Tag> : <Tag color="green">enabled</Tag>
render={(text: Boolean, record: RuleDataTable, index) => {
return text ? <Tag color="red">disabled</Tag> :
<Tag color="green">enabled</Tag>
}}
/>
<Column title="Sources" dataIndex="sourceLabel"
render={(text, record:RuleDataTable, index) => {
render={(text, record: RuleDataTable, index) => {
//return <Button type="link" onClick={() => toggleModalGroups(`${record.Name} - Sources`, record.Source, true)}>{text}</Button>
return renderPopoverGroups(text, record.sources,record as RuleDataTable)
return renderPopoverGroups(text, record.sources, record as RuleDataTable)
}}
/>
<Column title="Direction" dataIndex="flow"
render={(text, record:RuleDataTable, index) => {
render={(text, record: RuleDataTable, index) => {
const s = {minWidth: 50, textAlign: "center"} as React.CSSProperties
if (text === "bidirect")
return <Tag color="processing" style={s}><img src={bidirect}/></Tag>
@@ -379,22 +433,23 @@ export const AccessControl = () => {
} else if (text === "destToSrc") {
return <Tag color="green" style={s}><img src={inbound}/></Tag>
}
return <Tag color="red" style={s}><CloseOutlined /></Tag>
return <Tag color="red" style={s}><CloseOutlined/></Tag>
}}
/>
<Column title="Destinations" dataIndex="destinationLabel"
render={(text, record:RuleDataTable, index) => {
render={(text, record: RuleDataTable, index) => {
//return <Button type="link" onClick={() => toggleModalGroups(`${record.name} - Destinations`, record.destinations, true)}>{text}</Button>
return renderPopoverGroups(text, record.destinations,record as RuleDataTable)
return renderPopoverGroups(text, record.destinations, record as RuleDataTable)
}}
/>
<Column title="" align="center"
render={(text, record, index) => {
if (deletedRule.loading || savedRule.loading) return <></>
return <Dropdown.Button type="text" overlay={actionsMenu} trigger={["click"]}
onVisibleChange={visible => {
if (visible) setRuleToAction(record as RuleDataTable)
}}></Dropdown.Button>
return <Dropdown.Button type="text" overlay={actionsMenu}
trigger={["click"]}
onOpenChange={visible => {
if (visible) setRuleToAction(record as RuleDataTable)
}}></Dropdown.Button>
}}
/>
</Table>
@@ -409,7 +464,9 @@ export const AccessControl = () => {
</Col>
</Row>
</Container>
<AccessControlModalGroups data={groupsToShow.groups} title={groupsToShow.title} visible={groupsToShow.modalVisible} onCancel={() => toggleModalGroups("", [], false)}/>
<AccessControlModalGroups data={groupsToShow.groups} title={groupsToShow.title}
visible={groupsToShow.modalVisible}
onCancel={() => toggleModalGroups("", [], false)}/>
<AccessControlNew/>
</>
)

299
src/views/Activity.tsx Normal file
View File

@@ -0,0 +1,299 @@
import React, {useEffect, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {actions as eventActions} from '../store/event';
import {Container} from "../components/Container";
import {Alert, Button, Card, Col, Input, Row, Select, Space, Table, Typography,} from "antd";
import {Event} from "../store/event/types";
import {filter} from "lodash";
import tableSpin from "../components/Spin";
import {useGetTokenSilently} from "../utils/token";
import UserUpdate from "../components/UserUpdate";
import {useOidcUser} from "@axa-fr/react-oidc";
import {capitalize, formatDateTime} from "../utils/common";
import {User} from "../store/user/types";
import {usePageSizeHelpers} from "../utils/pageSize";
import {QuestionCircleFilled} from "@ant-design/icons";
import {Link} from "react-router-dom";
const {Title, Paragraph, Text} = Typography;
const {Column} = Table;
interface EventDataTable extends Event {
}
export const Activity = () => {
const {onChangePageSize,pageSizeOptions,pageSize} = usePageSizeHelpers()
const {getTokenSilently} = useGetTokenSilently()
const {oidcUser} = useOidcUser();
const dispatch = useDispatch()
const events = useSelector((state: RootState) => state.event.data);
const failed = useSelector((state: RootState) => state.event.failed);
const loading = useSelector((state: RootState) => state.event.loading);
const users = useSelector((state: RootState) => state.user.data);
const setupKeys = useSelector((state: RootState) => state.setupKey.data);
const [textToSearch, setTextToSearch] = useState('');
const [dataTable, setDataTable] = useState([] as EventDataTable[]);
const transformDataTable = (d: Event[]): EventDataTable[] => {
return d.map(p => ({key: p.id, ...p} as EventDataTable))
}
useEffect(() => {
dispatch(eventActions.getEvents.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}, [])
useEffect(() => {
setDataTable(transformDataTable(events))
}, [events])
useEffect(() => {
setDataTable(transformDataTable(filterDataTable()))
}, [textToSearch])
const filterDataTable = (): Event[] => {
const t = textToSearch.toLowerCase().trim()
let usrsMatch: User[] = filter(users, (u: User) => (u.name)?.toLowerCase().includes(t) || (u.email)?.toLowerCase().includes(t)) as User[]
let f: Event[] = filter(events, (f: Event) =>
((f.activity || f.id).toLowerCase().includes(t) || t === "" || usrsMatch.find(u => u.id === f.initiator_id))
) as Event[]
return f
}
const onChangeTextToSearch = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setTextToSearch(e.target.value)
};
const searchDataTable = () => {
const data = filterDataTable()
setDataTable(transformDataTable(data))
}
const getActivityRow = (group:string,text:string) => {
return <Row> <Text>Group <Text type="secondary">{group}</Text> {text}</Text> </Row>
}
const renderActivity = (event: EventDataTable) => {
let body = <Text>{event.activity}</Text>
switch (event.activity_code) {
case "peer.group.add":
return getActivityRow(event.meta.group,"added to peer")
case "peer.group.delete":
return getActivityRow(event.meta.group,"removed from peer")
case "user.group.add":
return getActivityRow(event.meta.group,"added to user")
case "user.group.delete":
return getActivityRow(event.meta.group,"removed from user")
case "setupkey.group.add":
return getActivityRow(event.meta.group,"added to setup key")
case "setupkey.group.delete":
return getActivityRow(event.meta.group,"removed setup key")
case "dns.setting.disabled.management.group.add":
return getActivityRow(event.meta.group,"added to disabled management DNS setting")
case "dns.setting.disabled.management.group.delete":
return getActivityRow(event.meta.group,"removed from disabled management DNS setting")
}
return body
}
const renderInitiator = (event: EventDataTable) => {
let body = <></>
const user = users?.find(u => u.id === event.initiator_id)
switch (event.activity_code) {
case "setupkey.peer.add":
const key = setupKeys?.find(k => k.id === event.initiator_id)
if (key) {
body = <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{key.name}</Text> </Row>
<Row> <Text type="secondary">Setup Key</Text> </Row>
</span>
}
break
default:
if (user) {
body = <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{user.name ? user.name : user.id}</Text> </Row>
<Row> <Text type="secondary">{user.email ? user.email : "User"}</Text> </Row>
</span>
return body
}
}
return body
}
const renderMultiRowSpan = (primaryRowText:string,secondaryRowText:string) => {
return <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{primaryRowText}</Text> </Row>
<Row> <Text type="secondary">{secondaryRowText}</Text> </Row>
</span>
}
const renderTarget = (event: EventDataTable) => {
if (event.activity_code === "account.create" || event.activity_code === "user.join") {
return "-"
}
const user = users?.find(u => u.id === event.target_id)
switch (event.activity_code) {
case "account.create":
case "user.join":
return "-"
case "rule.add":
case "rule.delete":
case "rule.update":
return renderMultiRowSpan(event.meta.name,"Rule")
case "policy.add":
case "policy.delete":
case "policy.update":
return renderMultiRowSpan(event.meta.name, "Policy")
case "setupkey.add":
case "setupkey.revoke":
case "setupkey.update":
case "setupkey.overuse":
let cType:string
cType = capitalize(event.meta.type)
return renderMultiRowSpan(event.meta.name,cType+" setup key "+event.meta.key)
case "group.add":
case "group.update":
return renderMultiRowSpan(event.meta.name,"Group")
case "nameserver.group.add":
case "nameserver.group.update":
case "nameserver.group.delete":
return renderMultiRowSpan(event.meta.name,"Nameserver group")
case "setupkey.peer.add":
case "user.peer.add":
case "user.peer.delete":
case "peer.ssh.enable":
case "peer.ssh.disable":
case "peer.rename":
case "peer.login.expiration.disable":
case "peer.login.expiration.enable":
return renderMultiRowSpan(event.meta.fqdn,event.meta.ip)
case "route.add":
case "route.delete":
case "route.update":
return renderMultiRowSpan(event.meta.name, "Route for range " + event.meta.network_range)
case "user.group.add":
case "user.group.delete":
case "user.role.update":
if (user) {
return renderMultiRowSpan(user.name ? user.name : user.id,user.email ? user.email : "User")
}
return "-"
case "setupkey.group.add":
case "setupkey.group.delete":
return renderMultiRowSpan(event.meta.setupkey,"Setup Key")
case "peer.group.add":
case "peer.group.delete":
return renderMultiRowSpan(event.meta.peer_fqdn,event.meta.peer_ip)
case "dns.setting.disabled.management.group.add":
case "dns.setting.disabled.management.group.delete":
case "account.setting.peer.login.expiration.enable":
case "account.setting.peer.login.expiration.disable":
case "account.setting.peer.login.expiration.update":
return renderMultiRowSpan("","System setting")
case "personal.access.token.create":
case "personal.access.token.delete":
if(user) {
return renderMultiRowSpan(event.meta.name, user.name ? user.name : event.target_id)
}
return "-"
case "user.invite":
if (user) {
return renderMultiRowSpan(user.name ? user.name : user.id,user.email ? user.email : "User")
}
break
default:
console.error("unknown event - missing handling", event.activity_code)
}
return event.target_id
}
return (
<>
<Container style={{paddingTop: "40px"}}>
<Row>
<Col span={24}>
<Title level={4}>Activity</Title>
<Paragraph>Here you can see all the account and network activity events</Paragraph>
<Space direction="vertical" size="large" style={{display: 'flex'}}>
<Row gutter={[16, 24]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} xxl={8} span={8}>
<Input allowClear value={textToSearch} onPressEnter={searchDataTable}
placeholder="Search..." onChange={onChangeTextToSearch}/>
</Col>
<Col xs={24} sm={24} md={11} lg={11} xl={11} xxl={11} span={11}>
<Space size="middle">
<Select value={pageSize.toString()} options={pageSizeOptions}
onChange={onChangePageSize} className="select-rows-per-page-en"/>
</Space>
</Col>
<Col xs={24}
sm={24}
md={5}
lg={5}
xl={5}
xxl={5} span={5}>
<Row justify="end">
<Col>
<Button icon={<QuestionCircleFilled/>} type="link" target="_blank"
href="https://netbird.io/docs/how-to-guides/activity-monitoring">Learn more about activity tracking</Button>
</Col>
</Row>
</Col>
</Row>
{failed &&
<Alert message={failed.message} description={failed.data ? failed.data.message : " "}
type="error" showIcon
closable/>
}
<Card bodyStyle={{padding: 0}}>
<Table
pagination={{
pageSize,
showSizeChanger: false,
showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} activity events`)
}}
className="card-table"
showSorterTooltip={false}
scroll={{x: true}}
loading={tableSpin(loading)}
dataSource={dataTable}
size="small"
>
<Column title="Timestamp" dataIndex="timestamp"
render={(text, record, index) => {
return formatDateTime(text)
}}
/>
<Column title="Activity" dataIndex="activity"
render={(text, record, index) => {
return renderActivity(record as EventDataTable)
}}
/>
<Column title="Initiated By" dataIndex="initiator_id"
render={(text, record, index) => {
return renderInitiator(record as EventDataTable)
}}
/>
<Column title="Target" dataIndex="target_id"
render={(text, record, index) => {
return renderTarget(record as EventDataTable)
}}
/>
</Table>
</Card>
</Space>
</Col>
</Row>
</Container>
<UserUpdate/>
</>
)
}
export default Activity;

78
src/views/DNS.tsx Normal file
View File

@@ -0,0 +1,78 @@
import React, {useEffect, useState} from 'react';
import {Container} from "../components/Container";
import {
Col,
Row,
Tabs,
Typography,
} from "antd";
import type { TabsProps } from 'antd';
import NameServerGroupUpdate from "../components/NameServerGroupUpdate";
import Nameservers from "./Nameservers";
import {actions as groupActions} from "../store/group";
import {useGetTokenSilently} from "../utils/token";
import {useDispatch, useSelector} from "react-redux";
import DNSSettingsForm from "./DNSSettings";
import {RootState} from "typesafe-actions";
import {actions as dnsSettingsActions} from '../store/dns-settings';
import {useGetGroupTagHelpers} from "../utils/groups";
const {Title, Paragraph} = Typography;
export const DNS = () => {
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const {
getGroupNamesFromIDs,
} = useGetGroupTagHelpers()
const dnsSettingsData = useSelector((state: RootState) => state.dnsSettings.data)
useEffect(() => {
dispatch(groupActions.getGroups.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}, [])
const nsTabKey = '1'
const items: TabsProps['items'] = [
{
key: nsTabKey,
label: 'Nameservers',
children: <Nameservers/>,
},
{
key: '2',
label: 'Settings',
children: <DNSSettingsForm/>,
},
]
const onTabClick = (key:string) => {
if (key == nsTabKey) {
if (!dnsSettingsData) return
dispatch(dnsSettingsActions.setDNSSettings({
disabled_management_groups: getGroupNamesFromIDs(dnsSettingsData.disabled_management_groups),
}))
}
}
return (
<>
<Container style={{paddingTop: "40px"}}>
<Row>
<Col span={24}>
<Tabs
defaultActiveKey={nsTabKey}
items={items}
onTabClick={onTabClick}
animated={{ inkBar: true, tabPane: false }}
tabPosition="top"
/>
</Col>
</Row>
</Container>
<NameServerGroupUpdate/>
</>
)
}
export default DNS;

176
src/views/DNSSettings.tsx Normal file
View File

@@ -0,0 +1,176 @@
import React, {useEffect, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {
Button,
Card,
Col,
Form,
message,
Select,
Space,
Typography,
} from "antd";
import {useGetTokenSilently} from "../utils/token";
import {useGetGroupTagHelpers} from "../utils/groups";
import {actions as dnsSettingsActions} from '../store/dns-settings';
import {DNSSettings, DNSSettingsToSave} from "../store/dns-settings/types";
import {actions as nsGroupActions} from "../store/nameservers";
const {Paragraph} = Typography;
const styleNotification = {marginTop: 85}
export const DNSSettingsForm = () => {
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const {
tagRender,
handleChangeTags,
dropDownRender,
optionRender,
tagGroups,
getExistingAndToCreateGroupsLists,
getGroupNamesFromIDs,
selectValidatorEmptyStrings
} = useGetGroupTagHelpers()
const dnsSettings = useSelector((state: RootState) => state.dnsSettings.dnsSettings)
const dnsSettingsData = useSelector((state: RootState) => state.dnsSettings.data)
const savedDNSSettings = useSelector((state: RootState) => state.dnsSettings.savedDNSSettings)
const loading = useSelector((state: RootState) => state.dnsSettings.loading);
const [form] = Form.useForm()
useEffect(() => {
dispatch(dnsSettingsActions.getDNSSettings.request({
getAccessTokenSilently: getTokenSilently,
payload: null
}));
}, []);
useEffect(() => {
if (!dnsSettingsData) return
dispatch(dnsSettingsActions.setDNSSettings({
disabled_management_groups: getGroupNamesFromIDs(dnsSettingsData.disabled_management_groups),
}))
}, [dnsSettingsData])
useEffect(() => {
form.setFieldsValue(dnsSettings)
}, [dnsSettings])
const createKey = 'saving';
useEffect(() => {
if (savedDNSSettings.loading) {
message.loading({content: 'Saving...', key: createKey, duration: 0, style: styleNotification});
} else if (savedDNSSettings.success) {
message.success({
content: 'DNS settings has been successfully saved.',
key: createKey,
duration: 2,
style: styleNotification
});
dispatch(dnsSettingsActions.setSavedDNSSettings({...savedDNSSettings, success: false}));
dispatch(dnsSettingsActions.resetSavedDNSSettings(null))
} else if (savedDNSSettings.error) {
let errorMsg = "Failed to update DNS settings"
switch (savedDNSSettings.error.statusCode) {
case 403:
errorMsg = "Failed to update DNS settings. You might not have enough permissions."
break
default:
errorMsg = savedDNSSettings.error.data.message ? savedDNSSettings.error.data.message : errorMsg
break
}
message.error({
content: errorMsg,
key: createKey,
duration: 5,
style: styleNotification
});
dispatch(dnsSettingsActions.setSavedDNSSettings({...savedDNSSettings, error: null}));
dispatch(nsGroupActions.resetSavedNameServerGroup(null))
}
}, [savedDNSSettings])
const handleFormSubmit = () => {
form.validateFields()
.then((values) => {
let dnsSettingsToSave = createDNSSettingsToSave(values)
dispatch(dnsSettingsActions.saveDNSSettings.request({
getAccessTokenSilently:getTokenSilently,
payload: dnsSettingsToSave
}))
})
.catch((errorInfo) => {
let msg = "please check the fields and try again"
if (errorInfo.errorFields) {
msg = errorInfo.errorFields[0].errors[0]
}
message.error({
content: msg,
duration: 1,
});
});
}
const createDNSSettingsToSave = (values: DNSSettings): DNSSettingsToSave => {
let [existingGroups, newGroups] = getExistingAndToCreateGroupsLists(values.disabled_management_groups)
return {
disabled_management_groups: existingGroups,
groupsToCreate: newGroups
} as DNSSettingsToSave
}
return (
<>
<Paragraph>Manage your account's DNS settings</Paragraph>
<Col>
<Form
name="basic"
autoComplete="off"
form={form}
onFinish={handleFormSubmit}
>
<Space direction={"vertical"}
style={{ display: 'flex' }}>
<Card
title="DNS Management"
loading={loading}
>
<Form.Item
label="Disable DNS management for these groups"
name="disabled_management_groups"
tooltip="Peers in these groups will have their DNS management disabled and require manual configuration for domain name resolution"
rules={[{validator: selectValidatorEmptyStrings}]}
>
<Select mode="tags"
style={{width: '100%'}}
tagRender={tagRender}
onChange={handleChangeTags}
dropdownRender={dropDownRender}
>
{
tagGroups.map(m =>
<Select.Option key={m}>{optionRender(m)}</Select.Option>
)
}
</Select>
</Form.Item>
</Card>
<Form.Item style={{ textAlign:'center' }} >
<Button type="primary" htmlType="submit">
Save
</Button>
</Form.Item>
</Space>
</Form>
</Col>
</>
)
}
export default DNSSettingsForm;

View File

@@ -0,0 +1,114 @@
import {Input, Select, Space} from 'antd';
import React, {useState} from 'react';
export interface ExpiresInValue {
number?: number;
interval?: string;
}
export interface SelectOption {
key: string,
title: string
}
interface ExpiresInInputProps {
value?: ExpiresInValue;
onChange?: (value: ExpiresInValue) => void;
options: SelectOption[];
disabled?: boolean;
}
export const secondsToExpiresIn = (expiresIn: number, availableOptions: string[]): ExpiresInValue => {
if (expiresIn == 0) {
return {interval: "day", number: 0}
}
let result = {interval: "hour", number: expiresIn / 3600}
availableOptions.forEach(opt => {
if (opt === "year" && (expiresIn % 31104000 === 0)) {
result = {interval: "year", number: expiresIn / 31104000}
} else if (opt === "month" && (expiresIn % 2592000 === 0)) {
result = {interval: "month", number: expiresIn / 2592000}
} else if (opt === "day" && (expiresIn % 86400 === 0)) {
result = {interval: "day", number: expiresIn / 86400}
} else if (opt === "hour" && (expiresIn % 3600 === 0)) {
result = {interval: "hour", number: expiresIn / 3600}
}
})
return result
}
export const expiresInToSeconds = (expiresIn: ExpiresInValue): number => {
if (!expiresIn.number || !expiresIn.interval) {
return 0
}
let multiplier = 0
switch (expiresIn.interval.toLowerCase()) {
case "hour":
multiplier = 3600
break
case "day":
multiplier = 24 * 3600
break
case "week":
multiplier = 7 * 24 * 3600
break
case "month":
multiplier = 30 * 24 * 3600
break
case "year":
multiplier = 365 * 24 * 3600
break
default:
multiplier = 0
}
return expiresIn.number * multiplier
}
const ExpiresInInput: React.FC<ExpiresInInputProps> = ({
value = {},
onChange,
options,
disabled= false,
}) => {
const [number, setNumber] = useState(60);
const [interval, setInterval] = useState("day");
const triggerChange = (changedValue: { number?: number; interval?: string }) => {
onChange?.({number, interval, ...value, ...changedValue});
};
const onNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newNumber = parseInt(e.target.value || '0', 10);
setNumber(newNumber);
triggerChange({number: newNumber});
};
const onIntervalChange = (newInterval: string) => {
setInterval(newInterval);
triggerChange({interval: newInterval});
};
return (
<Space>
<Input
type="number"
value={value.number || number}
onChange={onNumberChange}
disabled={disabled}
/>
<Select style={{width: "100%"}}
value={value?.interval || interval}
disabled={disabled}
onChange={onIntervalChange}>
{options.map(m =>
<Select.Option key={m.key}>{m.title}</Select.Option>)}
</Select>
</Space>
);
};
export default ExpiresInInput;

466
src/views/Nameservers.tsx Normal file
View File

@@ -0,0 +1,466 @@
import React, {useEffect, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {actions as nsGroupActions} from '../store/nameservers';
import {Container} from "../components/Container";
import {
Alert,
Button,
Card,
Col,
Dropdown,
Input,
Menu,
message,
Modal,
Popover,
Radio,
RadioChangeEvent,
Row,
Select,
Space,
Table,
Tag,
Typography,
} from "antd";
import {filter} from "lodash";
import tableSpin from "../components/Spin";
import {useGetTokenSilently} from "../utils/token";
import {actions as groupActions} from "../store/group";
import {Group} from "../store/group/types";
import {TooltipPlacement} from "antd/es/tooltip";
import {NameServer, NameServerGroup} from "../store/nameservers/types";
import NameServerGroupUpdate from "../components/NameServerGroupUpdate";
import {ExclamationCircleOutlined} from "@ant-design/icons";
import {useGetGroupTagHelpers} from "../utils/groups";
import {usePageSizeHelpers} from "../utils/pageSize";
const {Title, Paragraph} = Typography;
const {Column} = Table;
const {confirm} = Modal;
interface NameserverGroupDataTable extends NameServerGroup {
key: string
}
const styleNotification = {marginTop: 85}
export const Nameservers = () => {
const {onChangePageSize,pageSizeOptions,pageSize} = usePageSizeHelpers()
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const {
getGroupNamesFromIDs,
} = useGetGroupTagHelpers()
const groups = useSelector((state: RootState) => state.group.data)
const nsGroup = useSelector((state: RootState) => state.nameserverGroup.data);
const failed = useSelector((state: RootState) => state.nameserverGroup.failed);
const loading = useSelector((state: RootState) => state.nameserverGroup.loading);
const updateNameServerGroupVisible = useSelector((state: RootState) => state.nameserverGroup.setupNewNameServerGroupVisible)
const savedNSGroup = useSelector((state: RootState) => state.nameserverGroup.savedNameServerGroup)
const [groupPopupVisible, setGroupPopupVisible] = useState(false as boolean | undefined)
const [nsGroupToAction, setNsGroupToAction] = useState(null as NameserverGroupDataTable | null);
const [textToSearch, setTextToSearch] = useState('');
const [optionAllEnable, setOptionAllEnable] = useState('enabled');
const [dataTable, setDataTable] = useState([] as NameserverGroupDataTable[]);
const [showTutorial, setShowTutorial] = useState(false)
const optionsAllEnabled = [{label: 'Enabled', value: 'enabled'}, {label: 'All', value: 'all'}]
// setUserAndView makes the UserUpdate drawer visible (right side) and sets the user object
const setUserAndView = (nsGroup: NameServerGroup) => {
dispatch(nsGroupActions.setSetupNewNameServerGroupVisible(true));
dispatch(nsGroupActions.setNameServerGroup({
id: nsGroup.id,
name: nsGroup.name,
primary: nsGroup.primary,
domains: nsGroup.domains,
description: nsGroup.description,
nameservers: nsGroup.nameservers,
groups: nsGroup.groups,
enabled: nsGroup.enabled,
} as NameServerGroup));
}
const transformDataTable = (d: NameServerGroup[]): NameserverGroupDataTable[] => {
return d.map(p => ({key: p.id, ...p} as NameserverGroupDataTable))
}
useEffect(() => {
dispatch(nsGroupActions.getNameServerGroups.request({
getAccessTokenSilently: getTokenSilently,
payload: null
}));
dispatch(groupActions.getGroups.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}, [])
useEffect(() => {
if (nsGroup.length > 0) {
setShowTutorial(false)
} else {
setShowTutorial(true)
}
setDataTable(transformDataTable(filterDataTable()))
}, [nsGroup])
useEffect(() => {
setDataTable(transformDataTable(filterDataTable()))
}, [textToSearch, optionAllEnable])
const filterDataTable = (): NameServerGroup[] => {
const t = textToSearch.toLowerCase().trim()
let f = filter(nsGroup, (f: NameServerGroup) =>
((f.name).toLowerCase().includes(t) ||
f.name.includes(t) || t === "" ||
getGroupNamesFromIDs(f.groups).find(u => u.toLowerCase().trim().includes(t)) ||
f.domains.find(d => d.toLowerCase().trim().includes(t)) ||
f.nameservers.find(n => n.ip.includes(t)))
) as NameServerGroup[]
if (optionAllEnable !== "all") {
f = filter(f, (f) => f.enabled)
}
return f
}
const onChangeAllEnabled = ({target: {value}}: RadioChangeEvent) => {
setOptionAllEnable(value)
}
const onChangeTextToSearch = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setTextToSearch(e.target.value)
};
const searchDataTable = () => {
setDataTable(transformDataTable(filterDataTable()))
}
const onClickEdit = () => {
dispatch(nsGroupActions.setSetupNewNameServerGroupVisible(true));
dispatch(nsGroupActions.setNameServerGroup({
id: nsGroupToAction?.id,
name: nsGroupToAction?.name,
primary: nsGroupToAction?.primary,
domains: nsGroupToAction?.domains,
description: nsGroupToAction?.description,
groups: nsGroupToAction?.groups,
enabled: nsGroupToAction?.enabled,
nameservers: nsGroupToAction?.nameservers,
} as NameServerGroup));
}
const showConfirmDelete = () => {
let name = nsGroupToAction ? nsGroupToAction.name : '';
confirm({
icon: <ExclamationCircleOutlined/>,
title: "Delete Nameserver group \"" + name + "\"",
width: 600,
content: <Space direction="vertical" size="small">
<Paragraph>Are you sure you want to delete this nameserver group from your account?</Paragraph>
</Space>,
okType: 'danger',
onOk() {
dispatch(nsGroupActions.deleteNameServerGroup.request({
getAccessTokenSilently: getTokenSilently,
payload: nsGroupToAction?.id || ''
}));
},
onCancel() {
setNsGroupToAction(null);
},
});
}
const renderPopoverGroups = (label: string, rowGroups: string[] | null, userToAction: NameserverGroupDataTable) => {
let groupsMap = new Map<string, Group>();
groups.forEach(g => {
groupsMap.set(g.id!, g)
})
let displayGroups: Group[] = []
if (rowGroups) {
displayGroups = rowGroups.filter(g => groupsMap.get(g)).map(g => groupsMap.get(g)!)
}
let btn = <Button type="link" onClick={() => setUserAndView(userToAction)}>{displayGroups.length}</Button>
if (!displayGroups || displayGroups!.length < 1) {
return btn
}
const content = displayGroups?.map((g, i) => {
const _g = g as Group
const peersCount = ` - ${_g.peers_count || 0} ${(!_g.peers_count || parseInt(_g.peers_count) !== 1) ? 'peers' : 'peer'} `
return (
<div key={i}>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{_g.name}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</div>
)
})
const mainContent = (<Space direction="vertical">{content}</Space>)
let popoverPlacement = "top"
if (content && content.length > 5) {
popoverPlacement = "rightTop"
}
return (
<Popover placement={popoverPlacement as TooltipPlacement}
key={userToAction.id}
onOpenChange={onPopoverVisibleChange}
open={groupPopupVisible}
content={mainContent}
title={null}>
{btn}
</Popover>
)
}
const renderPopoverDomains = (_: string, inputDomains: string[] | null, userToAction: NameserverGroupDataTable) => {
var domains = [] as string[]
if (inputDomains?.length) {
domains = inputDomains
}
let btn = <Button type="link"
onClick={() => setUserAndView(userToAction)}>{domains.length ? domains.length : 0}</Button>
if (!domains || domains!.length < 1) {
return btn
}
const content = domains?.map((d, i) => {
return (
<div key={i}>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{d}</strong>
</Tag>
</div>
)
})
const mainContent = (<Space direction="vertical">{content}</Space>)
let popoverPlacement = "top"
if (content && content.length > 5) {
popoverPlacement = "rightTop"
}
return (
<Popover placement={popoverPlacement as TooltipPlacement}
key={userToAction.id}
onOpenChange={onPopoverVisibleChange}
open={groupPopupVisible}
content={mainContent}
title={null}>
{btn}
</Popover>
)
}
useEffect(() => {
if (updateNameServerGroupVisible) {
setGroupPopupVisible(false)
}
}, [updateNameServerGroupVisible])
const createKey = 'saving';
useEffect(() => {
if (savedNSGroup.loading) {
message.loading({content: 'Saving...', key: createKey, duration: 0, style: styleNotification});
} else if (savedNSGroup.success) {
message.success({
content: 'Nameserver has been successfully saved.',
key: createKey,
duration: 2,
style: styleNotification
});
dispatch(nsGroupActions.setSetupNewNameServerGroupVisible(false));
dispatch(nsGroupActions.setSavedNameServerGroup({...savedNSGroup, success: false}));
dispatch(nsGroupActions.resetSavedNameServerGroup(null))
} else if (savedNSGroup.error) {
let errorMsg = "Failed to update nameserver group"
switch (savedNSGroup.error.statusCode) {
case 403:
errorMsg = "Failed to update nameserver group. You might not have enough permissions."
break
default:
errorMsg = savedNSGroup.error.data.message ? savedNSGroup.error.data.message : errorMsg
break
}
message.error({
content: errorMsg,
key: createKey,
duration: 5,
style: styleNotification
});
dispatch(nsGroupActions.setSavedNameServerGroup({...savedNSGroup, error: null}));
dispatch(nsGroupActions.resetSavedNameServerGroup(null))
}
}, [savedNSGroup])
const onPopoverVisibleChange = () => {
if (updateNameServerGroupVisible) {
setGroupPopupVisible(false)
} else {
setGroupPopupVisible(undefined)
}
}
const itemsMenuAction = [
{
key: "edit",
label: (<Button type="text" onClick={() => onClickEdit()}>View</Button>)
},
{
key: "delete",
label: (<Button type="text" onClick={() => showConfirmDelete()}>Delete</Button>)
},
]
const actionsMenu = (<Menu items={itemsMenuAction}></Menu>)
const onClickAddNewNSGroup = () => {
dispatch(nsGroupActions.setSetupNewNameServerGroupVisible(true));
dispatch(nsGroupActions.setNameServerGroup({
enabled: true,
primary: true,
} as NameServerGroup))
}
return (
<>
<Paragraph>Add nameservers for domain name resolution in your NetBird network</Paragraph>
<Space direction="vertical" size="large" style={{display: 'flex'}}>
<Row gutter={[16, 24]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} xxl={8} span={8}>
<Input allowClear value={textToSearch} onPressEnter={searchDataTable}
placeholder="Search..." onChange={onChangeTextToSearch}/>
</Col>
<Col xs={24} sm={24} md={11} lg={11} xl={11} xxl={11} span={11}>
<Space size="middle">
<Radio.Group
options={optionsAllEnabled}
onChange={onChangeAllEnabled}
value={optionAllEnable}
optionType="button"
buttonStyle="solid"
/>
<Select value={pageSize.toString()} options={pageSizeOptions}
onChange={onChangePageSize} className="select-rows-per-page-en"/>
</Space>
</Col>
<Col xs={24}
sm={24}
md={5}
lg={5}
xl={5}
xxl={5} span={5}>
<Row justify="end">
<Col>
{!showTutorial &&
<Button type="primary" onClick={onClickAddNewNSGroup}>Add
Nameserver</Button>}
</Col>
</Row>
</Col>
</Row>
{failed &&
<Alert message={failed.code} description={failed.message} type="error" showIcon
closable/>
}
<Card bodyStyle={{padding: 0}}>
<Table
pagination={{
pageSize,
showSizeChanger: false,
showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} nameservers`)
}}
// className="card-table"
className={`access-control-table ${showTutorial ? "card-table card-table-no-placeholder" : "card-table"}`}
showSorterTooltip={false}
scroll={{x: true}}
loading={tableSpin(loading)}
dataSource={dataTable}>
<Column title="Name" dataIndex="name" align="center"
onFilter={(value: string | number | boolean, record) => (record as any).name.includes(value)}
sorter={(a, b) => ((a as any).name.localeCompare((b as any).name))}
defaultSortOrder='ascend'
render={(text, record) => {
return <Button type="text"
onClick={() => setUserAndView(record as NameserverGroupDataTable)}
className="tooltip-label">{(text && text.trim() !== "") ? text : (record as NameServerGroup).id}</Button>
}}
/>
<Column title="Status" dataIndex="enabled" align="center"
render={(text: Boolean) => {
return text ? <Tag color="green">enabled</Tag> :
<Tag color="red">disabled</Tag>
}}
/>
<Column title="Nameservers" dataIndex="nameservers" align="center"
render={(nameservers: NameServer[]) => (
<>
{nameservers.map(nameserver => (
<Tag key={nameserver.ip}>
{nameserver.ip}
</Tag>
))}
</>
)}
/>
<Column title="All domains" dataIndex="primary" align="center"
render={(text: Boolean) => {
return text ? <Tag color="blue">yes</Tag> :
<Tag>no</Tag>
}}
/>
<Column title="Match domains" dataIndex="domains" align="center"
render={(text, record: NameserverGroupDataTable) => {
return renderPopoverDomains(text, record.domains, record)
}}
/>
<Column title="Groups" dataIndex="groupsCount" align="center"
render={(text, record: NameserverGroupDataTable) => {
return renderPopoverGroups(text, record.groups, record)
}}
/>
<Column title="" align="center" width="30px"
render={(text, record) => {
return (
<Dropdown.Button type="text" overlay={actionsMenu}
trigger={["click"]}
onOpenChange={visible => {
if (visible) setNsGroupToAction(record as NameserverGroupDataTable)
}}></Dropdown.Button>)
}}
/>
</Table>
{showTutorial &&
<Space direction="vertical" size="small" align="center"
style={{display: 'flex', padding: '45px 15px', justifyContent: 'center'}}>
<Paragraph type="secondary"
style={{textAlign: "center", whiteSpace: "pre-line"}}>
It looks like you don't have any nameservers. {"\n"}
Get started by adding one to your network!
</Paragraph>
<Button type="primary" onClick={onClickAddNewNSGroup}>Add
Nameserver</Button>
</Space>
}
</Card>
</Space>
</>
)
}
export default Nameservers;

View File

@@ -1,12 +1,12 @@
import React, {useEffect, useState} from 'react';
import {Link} from 'react-router-dom';
import {capitalize, formatOS, timeAgo} from "../utils/common";
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {actions as peerActions} from '../store/peer';
import {actions as groupActions} from '../store/group';
import {actions as routeActions} from '../store/route';
import {Container} from "../components/Container";
import {useOidcAccessToken} from '@axa-fr/react-oidc';
import {ExclamationCircleOutlined, ReloadOutlined,} from '@ant-design/icons';
import {
Alert,
Button,
@@ -30,29 +30,28 @@ import {
Tooltip,
Typography
} from "antd";
import {Peer} from "../store/peer/types";
import {Peer, PeerDataTable} from "../store/peer/types";
import {filter} from "lodash"
import {formatOS, timeAgo} from "../utils/common";
import {ExclamationCircleOutlined} from "@ant-design/icons";
import ButtonCopyMessage from "../components/ButtonCopyMessage";
import {Group, GroupPeer} from "../store/group/types";
import PeerUpdate from "../components/PeerUpdate";
import tableSpin from "../components/Spin";
import {TooltipPlacement} from "antd/es/tooltip";
import {useGetTokenSilently} from "../utils/token";
import {actions as userActions} from "../store/user";
import ButtonCopyMessage from "../components/ButtonCopyMessage";
import {usePageSizeHelpers} from "../utils/pageSize";
import AddPeerPopup from "../components/addpeer/AddPeerPopup";
import {getLocalItem, setLocalItem, StorageKey} from "../services/local";
const {Title, Paragraph, Text} = Typography;
const {Column} = Table;
const {confirm} = Modal;
interface PeerDataTable extends Peer {
key: string;
groups: Group[];
groupsCount: number;
}
export const Peers = () => {
const {accessToken} = useOidcAccessToken()
const {onChangePageSize, pageSizeOptions, pageSize} = usePageSizeHelpers()
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const peers = useSelector((state: RootState) => state.peer.data);
@@ -64,18 +63,18 @@ export const Peers = () => {
const loadingGroups = useSelector((state: RootState) => state.group.loading);
const savedGroups = useSelector((state: RootState) => state.peer.savedGroups);
const updatedPeer = useSelector((state: RootState) => state.peer.updatedPeer);
const updateGroupsVisible = useSelector((state: RootState) => state.peer.updateGroupsVisible)
const users = useSelector((state: RootState) => state.user.data);
const [addPeerModalOpen, setAddPeerModalOpen] = useState(false);
const [textToSearch, setTextToSearch] = useState('');
const [optionOnOff, setOptionOnOff] = useState('all');
const [pageSize, setPageSize] = useState(10);
const [dataTable, setDataTable] = useState([] as PeerDataTable[]);
const [peerToAction, setPeerToAction] = useState(null as PeerDataTable | null);
const pageSizeOptions = [
{label: "5", value: "5"},
{label: "10", value: "10"},
{label: "15", value: "15"}
]
const [groupPopupVisible, setGroupPopupVisible] = useState(false as boolean | undefined)
const [showTutorial, setShowTutorial] = useState(false)
const [hadFirstRun, setHadFirstRun] = useState(true)
const [confirmModal, confirmModalContextHolder] = Modal.useModal();
const optionsOnOff = [{label: 'Online', value: 'on'}, {label: 'All', value: 'all'}]
@@ -92,7 +91,6 @@ export const Peers = () => {
const actionsMenu = (<Menu items={itemsMenuAction}></Menu>)
const transformDataTable = (d: Peer[]): PeerDataTable[] => {
const peer_ids = d.map(_p => _p.id)
return d.map((p) => {
const gs = groups
.filter(g => g.peers?.find((_p: GroupPeer) => _p.id === p.id))
@@ -106,13 +104,36 @@ export const Peers = () => {
})
}
const refresh = () => {
dispatch(userActions.getUsers.request({getAccessTokenSilently: getTokenSilently, payload: null}));
dispatch(peerActions.getPeers.request({getAccessTokenSilently: getTokenSilently, payload: null}));
dispatch(groupActions.getGroups.request({getAccessTokenSilently: getTokenSilently, payload: null}));
dispatch(routeActions.getRoutes.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}
useEffect(() => {
dispatch(peerActions.getPeers.request({getAccessTokenSilently: accessToken, payload: null}));
dispatch(groupActions.getGroups.request({getAccessTokenSilently: accessToken, payload: null}));
dispatch(routeActions.getRoutes.request({getAccessTokenSilently: accessToken, payload: null}));
getLocalItem<boolean>(StorageKey.hadFirstRun).then(f => setHadFirstRun(f === null? false : f))
refresh()
}, [])
useEffect(() => {
if (!hadFirstRun) {
setLocalItem(StorageKey.hadFirstRun, true).then()
setAddPeerModalOpen(true)
} else {
setAddPeerModalOpen(false)
}
}, [hadFirstRun])
useEffect(() => {
if (peers.length) {
setShowTutorial(false)
if (!hadFirstRun) {
setHadFirstRun(true)
}
} else {
setShowTutorial(true)
}
setDataTable(transformDataTable(peers))
}, [peers, groups])
@@ -174,10 +195,11 @@ export const Peers = () => {
dispatch(peerActions.setUpdatedPeer({...updatedPeer, success: false}))
dispatch(peerActions.resetUpdatedPeer(null))
} else if (updatedPeer.error) {
let msg = updatedPeer.error.data ? capitalize(updatedPeer.error.data.message) : updatedPeer.error.message
message.error({
content: 'Failed to update peer. You might not have enough permissions.',
content: msg,
key: updatePeerKey,
duration: 2,
duration: 3,
style
});
dispatch(peerActions.setUpdatedPeer({...updatedPeer, error: null}))
@@ -187,8 +209,14 @@ export const Peers = () => {
const filterDataTable = (): Peer[] => {
const t = textToSearch.toLowerCase().trim()
let f: Peer[] = filter(peers, (f: Peer) =>
(f.name.toLowerCase().includes(t) || f.ip.includes(t) || f.os.includes(t) || t === "")
let f: Peer[] = filter(peers, (f: Peer) => {
let userEmail: string | null
const u = users?.find(u => u.id === f.user_id)?.email
userEmail = u ? u : ""
return (f.name.toLowerCase().includes(t) || f.ip.includes(t) || f.os.includes(t) || t === "" ||
f.groups?.find(u => u.name.toLowerCase().trim().includes(t)) ||
(userEmail && userEmail.toLowerCase().includes(t)))
}
) as Peer[]
if (optionOnOff === "on") {
f = filter(peers, (f: Peer) => f.connected)
@@ -196,6 +224,14 @@ export const Peers = () => {
return f
}
const getGroupNamesFromIDs = (groupIDList: string[] | undefined): string[] => {
if (!groupIDList) {
return []
}
return groups?.filter(g => groupIDList.includes(g.id!)).map(g => g.name || '') || []
}
const onChangeTextToSearch = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setTextToSearch(e.target.value)
};
@@ -209,15 +245,10 @@ export const Peers = () => {
setOptionOnOff(value)
}
const onChangePageSize = (value: string) => {
setPageSize(parseInt(value.toString()))
}
const showConfirmDelete = () => {
let peerRoutes: string[] = []
routes.forEach((r) => {
if (r.peer == peerToAction?.ip) {
if (r.peer == peerToAction?.id) {
peerRoutes.push(r.network_id)
}
})
@@ -240,7 +271,9 @@ export const Peers = () => {
<Alert
message={
<div>
<>This peer is part of one or more network routes. Removing this peer will disable the following routes:</>
<>This peer is part of one or more network routes. Removing this peer will disable the
following routes:
</>
<List
dataSource={peerRoutes}
renderItem={item => <List.Item><Text strong>- {item}</Text></List.Item>}
@@ -257,16 +290,15 @@ export const Peers = () => {
</div>
}
let name = peerToAction ? peerToAction.name : ''
confirm({
confirmModal.confirm({
icon: <ExclamationCircleOutlined/>,
title: "Delete peer \"" + name + "\"",
width: 600,
content: contentModule,
okType: 'danger',
onOk() {
dispatch(peerActions.deletedPeer.request({
getAccessTokenSilently: accessToken,
payload: peerToAction ? peerToAction.ip : ''
getAccessTokenSilently: getTokenSilently,
payload: (peerToAction && peerToAction.id) ? peerToAction.id! : ""
}));
},
onCancel() {
@@ -276,12 +308,11 @@ export const Peers = () => {
}
const showConfirmEnableSSH = (record: PeerDataTable) => {
confirm({
confirmModal.confirm({
icon: <ExclamationCircleOutlined/>,
title: "Enable SSH Server for \"" + record.name + "\"?",
width: 600,
content: "Experimental feature. Enabling this option allows remote SSH access to this machine from other connected network participants.",
okType: 'danger',
onOk() {
handleSwitchSSH(record, true)
},
@@ -296,7 +327,7 @@ export const Peers = () => {
ssh_enabled: checked,
name: record.name
} as Peer
dispatch(peerActions.updatePeer.request({getAccessTokenSilently: accessToken, payload: peer}));
dispatch(peerActions.updatePeer.request({getAccessTokenSilently: getTokenSilently, payload: peer}));
}
@@ -305,6 +336,20 @@ export const Peers = () => {
dispatch(peerActions.setPeer(peerToAction as Peer))
}
useEffect(() => {
if (updateGroupsVisible) {
setGroupPopupVisible(false)
}
}, [updateGroupsVisible])
const onPopoverVisibleChange = (b: boolean) => {
if (updateGroupsVisible) {
setGroupPopupVisible(false)
} else {
setGroupPopupVisible(undefined)
}
}
const setUpdateGroupsVisible = (peerToAction: Peer, status: boolean) => {
if (status) {
dispatch(peerActions.setPeer({...peerToAction}))
@@ -339,24 +384,81 @@ export const Peers = () => {
return (
<Popover placement={popoverPlacement as TooltipPlacement} key={peerToAction.key} content={mainContent}
onOpenChange={onPopoverVisibleChange} open={groupPopupVisible}
title={null}>
<Button type="link" onClick={() => setUpdateGroupsVisible(peerToAction, true)}>{label}</Button>
</Popover>
)
}
const renderAddress = (peer: PeerDataTable) => {
if (!peer.dns_label) {
return <ButtonCopyMessage keyMessage={peer.key}
toCopy={peer.ip}
body={peer.ip}
messageText={'IP copied'}
styleNotification={{}}/>
}
const body = <span style={{textAlign: "left"}}>
<Row>
<ButtonCopyMessage keyMessage={peer.dns_label}
toCopy={peer.dns_label}
body={peer.dns_label}
messageText={'Peer domain copied'}
styleNotification={{}}/>
</Row>
<Row>
<ButtonCopyMessage keyMessage={peer.ip}
toCopy={peer.ip}
body={<Text type="secondary">{peer.ip}</Text>}
messageText={'Peer IP copied'}
style={{marginTop: '-10px'}}
styleNotification={{}}/>
</Row>
</span>
return body
}
const renderName = (peer: PeerDataTable) => {
const userEmail = users?.find(u => u.id === peer.user_id)?.email
let expiry =!peer.login_expiration_enabled ? <div><Tag><Text type="secondary" style={{fontSize: 10}}>expiration disabled</Text></Tag></div> : null
if (!userEmail) {
return <Button type="text" style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}
onClick={() => setUpdateGroupsVisible(peer, true)}>
<span style={{textAlign: "left"}}>
<Row><Text strong>{peer.name}</Text></Row>
</span>
</Button>
}
return <div>
<Button type="text"
onClick={() => setUpdateGroupsVisible(peer, true)}>
<span style={{textAlign: "left"}}>
<Row> <Text strong>{peer.name}</Text></Row>
<Row><Text type="secondary">{userEmail}</Text></Row>
<Row> {expiry}</Row>
</span>
</Button>
</div>
}
return (
<>
<Container style={{paddingTop: "40px"}}>
<Row>
<Col span={24}>
<Title level={4}>Peers</Title>
<Paragraph>A list of all the machines in your account including their name, IP and
status.</Paragraph>
{showTutorial && <Paragraph type={"secondary"}>A list of all the machines in your account including their name, IP and
status.</Paragraph>}
{!showTutorial && <Paragraph>A list of all the machines in your account including their name, IP and
status.</Paragraph>}
<Space direction="vertical" size="large" style={{display: 'flex'}}>
<Row gutter={[16, 24]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} xxl={8} span={8}>
{/*<Input.Search allowClear value={textToSearch} onPressEnter={searchDataTable} onSearch={searchDataTable} placeholder="Search..." onChange={onChangeTextToSearch} />*/}
<Input allowClear value={textToSearch} onPressEnter={searchDataTable}
placeholder="Search..." onChange={onChangeTextToSearch}/>
</Col>
@@ -368,8 +470,10 @@ export const Peers = () => {
value={optionOnOff}
optionType="button"
buttonStyle="solid"
disabled={showTutorial}
/>
<Select value={pageSize.toString()} options={pageSizeOptions}
disabled={showTutorial}
onChange={onChangePageSize} className="select-rows-per-page-en"/>
</Space>
</Col>
@@ -381,25 +485,24 @@ export const Peers = () => {
xxl={5} span={5}>
<Row justify="end">
<Col>
<Link to="/add-peer" className="ant-btn ant-btn-primary ant-btn-block">Add
Peer</Link>
{!showTutorial && <Button type="primary" onClick={() => setAddPeerModalOpen(true)}>Add peer</Button>}
</Col>
</Row>
</Col>
</Row>
{failed &&
<Alert message={failed.code} description={failed.message} type="error" showIcon
<Alert message={failed.message} description={failed.data ? failed.data.message : " "}
type="error" showIcon
closable/>
}
{/*{loading && <Loading/>}*/}
<Card bodyStyle={{padding: 0}}>
<Table
{!showTutorial && (<Table
pagination={{
pageSize,
showSizeChanger: false,
showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} peers`)
}}
className="card-table"
className={`access-control-table ${showTutorial ? "card-table card-table-no-placeholder" : "card-table"}`}
showSorterTooltip={false}
scroll={{x: true}}
loading={tableSpin(loading)}
@@ -407,13 +510,13 @@ export const Peers = () => {
<Column title="Name" dataIndex="name"
onFilter={(value: string | number | boolean, record) => (record as any).name.includes(value)}
defaultSortOrder='ascend'
align="left"
sorter={(a, b) => ((a as any).name.localeCompare((b as any).name))}
render={(text: string, record: PeerDataTable,) => {
return <Button type="text"
onClick={() => setUpdateGroupsVisible(record, true)}>{text}</Button>
return renderName(record)
}}
/>
<Column title="IP" dataIndex="ip"
<Column title="Address" dataIndex="ip"
sorter={(a, b) => {
const _a = (a as any).ip.split('.')
const _b = (b as any).ip.split('.')
@@ -421,16 +524,25 @@ export const Peers = () => {
const b_s = _b.map((i: any) => i.padStart(3, '0')).join()
return a_s.localeCompare(b_s)
}}
render={(text, record, index) => {
return <ButtonCopyMessage keyMessage={(record as PeerDataTable).key}
text={text} messageText={'IP copied!'}
styleNotification={{}}/>
render={(text: string, record: PeerDataTable, index: number) => {
return renderAddress(record)
}}
/>
<Column title="Status" dataIndex="connected" align="center"
render={(text, record, index) => {
return text ? <Tag color="green">online</Tag> :
render={(text, record: PeerDataTable, index) => {
let status = text ? <Tag color="green">online</Tag> :
<Tag color="red">offline</Tag>
if (record.login_expired) {
return <Tooltip
title="The peer is offline and needs to be re-authenticated because its login has expired ">
<Tag color="orange">needs login</Tag>
</Tooltip>
}
return status
}}
/>
<Column title="Groups" dataIndex="groupsCount" align="center"
@@ -467,7 +579,10 @@ export const Peers = () => {
<Column title="LastSeen" dataIndex="last_seen"
render={(text, record, index) => {
return (record as PeerDataTable).connected ? 'just now' : timeAgo(text)
let dt = new Date(text)
return <Popover content={dt.toLocaleString()}>
{(record as PeerDataTable).connected ? 'just now' : timeAgo(text)}
</Popover>
}}
/>
<Column title="OS" dataIndex="os"
@@ -480,18 +595,49 @@ export const Peers = () => {
render={(text, record, index) => {
return <Dropdown.Button type="text" overlay={actionsMenu}
trigger={["click"]}
onVisibleChange={visible => {
onOpenChange={visible => {
if (visible) setPeerToAction(record as PeerDataTable)
}}></Dropdown.Button>
}}
/>
</Table>
</Table>)}
{showTutorial &&
<Space direction="vertical" size="small" align="center"
style={{display: 'flex', padding: '45px 15px', justifyContent: 'center'}}>
<Title level={4}
style={{textAlign: "center"}}>
Get Started
</Title>
<Paragraph
style={{textAlign: "center", whiteSpace: "pre-line"}}>
It looks like you don't have any connected machines. {"\n"}
Get started by adding one to your network.
</Paragraph>
<Button size={"middle"} type="primary" onClick={() => setAddPeerModalOpen(true)}>
Add new peer
</Button>
</Space>
}
</Card>
</Space>
</Col>
</Row>
</Container>
<PeerUpdate/>
<Modal
open={addPeerModalOpen}
onOk={() => setAddPeerModalOpen(false)}
onCancel={() => {
setAddPeerModalOpen(false)
setHadFirstRun(true)
}}
footer={[]}
width={780}
>
<AddPeerPopup greeting={!hadFirstRun ? "Hi there!" : ""} headline={!hadFirstRun ? "It's time to add your first device." : "Add new peer"}/>
</Modal>
{confirmModalContextHolder}
</>
)
}

View File

@@ -1,95 +1,118 @@
import React, {useEffect, useState} from 'react';
import {
Alert,
Button, Card,
Col, Dropdown, Input, Menu, message, Modal, Radio, RadioChangeEvent,
Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, Divider
Button,
Card,
Col,
Divider,
Dropdown,
Input,
Menu,
message,
Modal, Popover,
Radio,
RadioChangeEvent,
Row,
Select,
Space,
Switch,
Table,
Tag,
Tooltip,
Typography
} from "antd";
import {Container} from "../components/Container";
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {Route} from "../store/route/types";
import {Route, RouteToSave} from "../store/route/types";
import {actions as routeActions} from "../store/route";
import {actions as peerActions} from "../store/peer";
import {filter, sortBy} from "lodash";
import { ExclamationCircleOutlined,QuestionCircleOutlined} from "@ant-design/icons";
import {ExclamationCircleOutlined, QuestionCircleOutlined} from "@ant-design/icons";
import RouteUpdate from "../components/RouteUpdate";
import tableSpin from "../components/Spin";
import {useOidcAccessToken} from '@axa-fr/react-oidc';
import {
masqueradeDisabledMSG,masqueradeEnabledMSG,
peerToPeerIP,initPeerMaps,
RouteDataTable,GroupedDataTable,
transformGroupedDataTable,transformDataTable
GroupedDataTable,
initPeerMaps,
masqueradeDisabledMSG,
masqueradeEnabledMSG,
peerToPeerIP,
RouteDataTable,
transformDataTable,
transformGroupedDataTable
} from '../utils/routes'
import {useGetTokenSilently} from "../utils/token";
import {Group} from "../store/group/types";
import {TooltipPlacement} from "antd/es/tooltip";
import {actions as groupActions} from "../store/group";
import {useGetGroupTagHelpers} from "../utils/groups";
import {usePageSizeHelpers} from "../utils/pageSize";
const { Title, Paragraph } = Typography;
const { Column } = Table;
const { confirm } = Modal;
const {Title, Paragraph, Text} = Typography;
const {Column} = Table;
const {confirm} = Modal;
export const Routes = () => {
const {accessToken} = useOidcAccessToken()
const {onChangePageSize,pageSizeOptions,pageSize} = usePageSizeHelpers()
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const {
getGroupNamesFromIDs,
} = useGetGroupTagHelpers()
const groups = useSelector((state: RootState) => state.group.data)
const routes = useSelector((state: RootState) => state.route.data);
const failed = useSelector((state: RootState) => state.route.failed);
const loading = useSelector((state: RootState) => state.route.loading);
const deletedRoute = useSelector((state: RootState) => state.route.deletedRoute);
const savedRoute = useSelector((state: RootState) => state.route.savedRoute);
const peers = useSelector((state: RootState) => state.peer.data)
const peers = useSelector((state: RootState) => state.peer.data)
const loadingPeer = useSelector((state: RootState) => state.peer.loading);
const setupNewRouteVisible = useSelector((state: RootState) => state.route.setupNewRouteVisible)
const [showTutorial, setShowTutorial] = useState(true)
const [textToSearch, setTextToSearch] = useState('');
const [optionAllEnable, setOptionAllEnable] = useState('enabled');
const [pageSize, setPageSize] = useState(5);
const [currentPage, setCurrentPage] = useState(1);
const [dataTable, setDataTable] = useState([] as RouteDataTable[]);
const [routeToAction, setRouteToAction] = useState(null as RouteDataTable | null);
const [groupedDataTable, setGroupedDataTable] = useState([] as GroupedDataTable[]);
const [expandRowsOnClick,setExpandRowsOnClick] = useState(true)
const [expandRowsOnClick, setExpandRowsOnClick] = useState(true)
const [groupPopupVisible, setGroupPopupVisible] = useState(false as boolean | undefined)
const [peerNameToIP, peerIPToName] = initPeerMaps(peers);
const pageSizeOptions = [
{label: "5", value: "5"},
{label: "10", value: "10"},
{label: "15", value: "15"}
]
const optionsAllEnabled = [{label: 'Enabled', value: 'enabled'},{label: 'All', value: 'all'}]
const optionsAllEnabled = [{label: 'Enabled', value: 'enabled'}, {label: 'All', value: 'all'}]
const itemsMenuAction = [
{
key: "view",
label: (<Button type="text" block onClick={() => onClickViewRoute()}>View</Button>)
},
// {
// key: "delete",
// label: (<Button type="text" block onClick={() => showConfirmDeactivate()}>Deactivate</Button>)
// },
{
key: "delete",
label: (<Button type="text" block onClick={() => showConfirmDelete()}>Delete</Button>)
}
]
const actionsMenu = (<Menu items={itemsMenuAction} ></Menu>)
const actionsMenu = (<Menu items={itemsMenuAction}></Menu>)
const isShowTutorial = (routes:Route[]):boolean => {
const isShowTutorial = (routes: Route[]): boolean => {
return (!routes.length || (routes.length === 1 && routes[0].network === "Default"))
}
useEffect(() => {
dispatch(routeActions.getRoutes.request({getAccessTokenSilently:accessToken, payload: null}));
dispatch(routeActions.getRoutes.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}, [peers])
useEffect(() => {
dispatch(peerActions.getPeers.request({getAccessTokenSilently:accessToken, payload: null}));
dispatch(peerActions.getPeers.request({getAccessTokenSilently: getTokenSilently, payload: null}));
dispatch(groupActions.getGroups.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}, [])
const filterGroupedDataTable = (routes:GroupedDataTable[]):GroupedDataTable[] => {
const filterGroupedDataTable = (routes: GroupedDataTable[]): GroupedDataTable[] => {
const t = textToSearch.toLowerCase().trim()
let f:GroupedDataTable[] = filter(routes, (f) =>
(f.network_id.toLowerCase().includes(t) ||f.network.toLowerCase().includes(t) || f.description.toLowerCase().includes(t) || t === "")
let f: GroupedDataTable[] = filter(routes, (f) =>
(f.network_id.toLowerCase().includes(t) || f.network.toLowerCase().includes(t) ||
f.description.toLowerCase().includes(t) || t === "" ||
getGroupNamesFromIDs(f.routesGroups).find(u => u.toLowerCase().trim().includes(t)) )
) as GroupedDataTable[]
if (optionAllEnable !== "all") {
f = filter(f, (f) => f.enabled)
@@ -97,47 +120,75 @@ export const Routes = () => {
return f
}
useEffect(() =>{
setGroupedDataTable(filterGroupedDataTable(transformGroupedDataTable(routes,peerIPToName)))
},[dataTable])
useEffect(() => {
setGroupedDataTable(filterGroupedDataTable(transformGroupedDataTable(routes, peers)))
}, [dataTable])
useEffect(() => {
setShowTutorial(isShowTutorial(routes))
setDataTable(sortBy(transformDataTable(routes,peerIPToName), "network_id"))
if (failed) {
setShowTutorial(false)
} else {
setShowTutorial(isShowTutorial(routes))
setDataTable(sortBy(transformDataTable(routes, peers), "network_id"))
}
}, [routes])
useEffect(() => {
setGroupedDataTable(filterGroupedDataTable(transformGroupedDataTable(routes,peerIPToName)))
setGroupedDataTable(filterGroupedDataTable(transformGroupedDataTable(routes, peers)))
}, [textToSearch, optionAllEnable])
const styleNotification = { marginTop: 85 }
const styleNotification = {marginTop: 85}
const saveKey = 'saving';
useEffect(() => {
if (savedRoute.loading) {
message.loading({ content: 'Saving...', key: saveKey, duration: 0, style: styleNotification })
message.loading({content: 'Saving...', key: saveKey, duration: 0, style: styleNotification})
} else if (savedRoute.success) {
message.success({ content: 'Route has been successfully updated.', key: saveKey, duration: 2, style: styleNotification });
message.success({
content: 'Route has been successfully updated.',
key: saveKey,
duration: 2,
style: styleNotification
});
dispatch(routeActions.setSetupNewRouteVisible(false))
dispatch(routeActions.setSavedRoute({ ...savedRoute, success: false }))
dispatch(routeActions.setSavedRoute({...savedRoute, success: false}))
dispatch(routeActions.resetSavedRoute(null))
} else if (savedRoute.error) {
message.error({ content: savedRoute.error.data? savedRoute.error.data : savedRoute.error.message, key: saveKey, duration: 2, style: styleNotification });
dispatch(routeActions.setSavedRoute({ ...savedRoute, error: null }))
let errorMsg = "Failed to update network route"
switch (savedRoute.error.statusCode) {
case 403:
errorMsg = "Failed to update network route. You might not have enough permissions."
break
default:
errorMsg = savedRoute.error.data.message ? savedRoute.error.data.message : errorMsg
break
}
message.error({
content: errorMsg,
key: saveKey,
duration: 5,
style: styleNotification
});
dispatch(routeActions.setSavedRoute({...savedRoute, error: null}))
dispatch(routeActions.resetSavedRoute(null))
}
}, [savedRoute])
const deleteKey = 'deleting';
useEffect(() => {
const style = { marginTop: 85 }
const style = {marginTop: 85}
if (deletedRoute.loading) {
message.loading({ content: 'Deleting...', key: deleteKey, style })
message.loading({content: 'Deleting...', key: deleteKey, style})
} else if (deletedRoute.success) {
message.success({ content: 'Route has been successfully disabled.', key: deleteKey, duration: 2, style })
message.success({content: 'Route has been successfully deleted.', key: deleteKey, duration: 2, style})
dispatch(routeActions.resetDeletedRoute(null))
} else if (deletedRoute.error) {
message.error({ content: 'Failed to remove route. You might not have enough permissions.', key: deleteKey, duration: 2, style })
message.error({
content: 'Failed to remove route. You might not have enough permissions.',
key: deleteKey,
duration: 2,
style
})
dispatch(routeActions.resetDeletedRoute(null))
}
}, [deletedRoute])
@@ -147,32 +198,28 @@ export const Routes = () => {
};
const searchDataTable = () => {
setGroupedDataTable(filterGroupedDataTable(transformGroupedDataTable(routes,peerIPToName)))
setGroupedDataTable(filterGroupedDataTable(transformGroupedDataTable(routes, peers)))
}
const onChangeAllEnabled = ({ target: { value } }: RadioChangeEvent) => {
const onChangeAllEnabled = ({target: {value}}: RadioChangeEvent) => {
setOptionAllEnable(value)
}
const onChangePageSize = (value: string) => {
setPageSize(parseInt(value.toString()))
}
const showConfirmDelete = () => {
let name = routeToAction ? routeToAction.network_id : '';
confirm({
icon: <ExclamationCircleOutlined />,
icon: <ExclamationCircleOutlined/>,
title: "Delete network route \"" + name + "\"",
width: 600,
content: <Space direction="vertical" size="small">
{routeToAction &&
<>
<Title level={5}>Delete netowork route "{routeToAction ? routeToAction.network_id : ''}"</Title>
<Paragraph>Are you sure you want to delete this route from your account?</Paragraph>
</>
}
<Paragraph>Are you sure you want to delete this route from your account?</Paragraph>
</Space>,
okType: 'danger',
onOk() {
dispatch(routeActions.deleteRoute.request({getAccessTokenSilently:accessToken, payload: routeToAction?.id || ''}));
dispatch(routeActions.deleteRoute.request({
getAccessTokenSilently: getTokenSilently,
payload: routeToAction?.id || ''
}));
},
onCancel() {
setRouteToAction(null);
@@ -181,9 +228,7 @@ export const Routes = () => {
}
const onClickAddNewRoute = () => {
dispatch(routeActions.setSetupNewRouteHA(true));
dispatch(routeActions.setSetupNewRouteVisible(true));
dispatch(routeActions.setRoute({
network: '',
@@ -204,7 +249,7 @@ export const Routes = () => {
network: routeToAction?.network,
network_id: routeToAction?.network_id,
description: routeToAction?.description,
peer: peerToPeerIP(routeToAction!.peer,peerNameToIP[routeToAction!.peer]),
peer: peerToPeerIP(routeToAction!.peer, peerNameToIP[routeToAction!.peer]),
metric: routeToAction?.metric,
masquerade: routeToAction?.masquerade,
enabled: routeToAction?.enabled
@@ -220,10 +265,11 @@ export const Routes = () => {
network: route.network,
network_id: route.network_id,
description: route.description,
peer: route.peer? peerToPeerIP(route.peer,peerNameToIP[route.peer]) : '',
metric: route.metric? route.metric : 9999,
peer: route.peer ? peerToPeerIP(route.peer_name, route.peer_ip) : '',
metric: route.metric ? route.metric : 9999,
masquerade: route.masquerade,
enabled: route.enabled
enabled: route.enabled,
groups: route.groups
} as Route))
dispatch(routeActions.setSetupNewRouteVisible(true));
}
@@ -239,7 +285,7 @@ export const Routes = () => {
}
confirm({
icon: <ExclamationCircleOutlined />,
icon: <ExclamationCircleOutlined/>,
title: tittle,
width: 600,
content: content,
@@ -252,17 +298,77 @@ export const Routes = () => {
});
}
const onPopoverVisibleChange = () => {
if (setupNewRouteVisible) {
setGroupPopupVisible(false)
} else {
setGroupPopupVisible(undefined)
}
}
function handleSwitchMasquerade(routeGroup: GroupedDataTable, checked: boolean) {
routeGroup.groupedRoutes.forEach((record) => {
const route = {
...record,
peer: peerNameToIP[record.peer],
masquerade: checked,
} as Route
dispatch(routeActions.saveRoute.request({getAccessTokenSilently:accessToken, payload: route}));
groupsToCreate: []
} as RouteToSave
dispatch(routeActions.saveRoute.request({getAccessTokenSilently: getTokenSilently, payload: route}));
})
}
const renderPopoverGroups = (label: string, rowGroups: string[] | null, userToAction: RouteDataTable) => {
let groupsMap = new Map<string, Group>();
groups.forEach(g => {
groupsMap.set(g.id!, g)
})
let displayGroups: Group[] = []
if (rowGroups) {
displayGroups = rowGroups.filter(g => groupsMap.get(g)).map(g => groupsMap.get(g)!)
}
let btn = <Button type="link" onClick={() => setRouteAndView(userToAction)}>{displayGroups.length}</Button>
if (!displayGroups || displayGroups!.length < 1) {
return btn
}
const content = displayGroups?.map((g, i) => {
const _g = g as Group
const peersCount = ` - ${_g.peers_count || 0} ${(!_g.peers_count || parseInt(_g.peers_count) !== 1) ? 'peers' : 'peer'} `
return (
<div key={i}>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{_g.name}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</div>
)
})
const mainContent = (<Space direction="vertical">{content}</Space>)
let popoverPlacement = "top"
if (content && content.length > 5) {
popoverPlacement = "rightTop"
}
return (
<Popover placement={popoverPlacement as TooltipPlacement}
key={userToAction.id}
onOpenChange={onPopoverVisibleChange}
open={groupPopupVisible}
content={mainContent}
title={null}>
{btn}
</Popover>
)
}
const expandedRowRender = (record: GroupedDataTable) => {
return <Table
@@ -274,19 +380,25 @@ export const Routes = () => {
size="small"
bordered={true}
>
<Column title="Routing Peer" dataIndex="peer" align="center"
<Column title="Routing Peer" dataIndex="peer_name" align="center"
onFilter={(value: string | number | boolean, record) => (record as any).peer.includes(value)}
sorter={(a, b) => ((a as any).peer.localeCompare((b as any).peer))}
render={(text, record) => {
return <span onClick={() => setRouteAndView(record as RouteDataTable)} className="tooltip-label">{text}</span>
return <span onClick={() => setRouteAndView(record as RouteDataTable)}
className="tooltip-label">{text}</span>
}}
/>
<Column title="Metric" dataIndex="metric" align="center"
onFilter={(value: string | number | boolean, record) => (record as any).metric.includes(value)}
sorter={(a, b) => ((a as any).metric - ((b as any).metric))}
/>
<Column title="Status" dataIndex="enabled" align="center"
render={(text:Boolean) => {
<Column title="Groups" dataIndex="groupsCount" align="center"
render={(text, record: RouteDataTable) => {
return renderPopoverGroups(text, record.groups, record)
}}
/>
<Column title="Routing peer status" dataIndex="enabled" align="center"
render={(text: Boolean) => {
return text ? <Tag color="green">enabled</Tag> : <Tag color="red">disabled</Tag>
}}
/>
@@ -294,7 +406,7 @@ export const Routes = () => {
render={(text, record) => {
if (deletedRoute.loading || savedRoute.loading) return <></>
return <Dropdown.Button type="text" overlay={actionsMenu} trigger={["click"]}
onVisibleChange={visible => {
onOpenChange={visible => {
if (visible) setRouteToAction(record as RouteDataTable)
}}></Dropdown.Button>
}}
@@ -302,17 +414,19 @@ export const Routes = () => {
</Table>
};
return(
return (
<>
<Container className="container-main">
<Row>
<Col span={24}>
<Title level={4}>Network Routes</Title>
<Paragraph>Network routes allow you to create routes to access other networks without installing NetBird on every resource.</Paragraph>
<Space direction="vertical" size="large" style={{ display: 'flex' }}>
<Paragraph>Network routes allow you to create routes to access other networks without installing
NetBird on every resource.</Paragraph>
<Space direction="vertical" size="large" style={{display: 'flex'}}>
<Row gutter={[16, 24]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} xxl={8} span={8}>
<Input allowClear value={textToSearch} onPressEnter={searchDataTable} placeholder="Search..." onChange={onChangeTextToSearch} />
<Input allowClear value={textToSearch} onPressEnter={searchDataTable}
placeholder="Search..." onChange={onChangeTextToSearch}/>
</Col>
<Col xs={24} sm={24} md={11} lg={11} xl={11} xxl={11} span={11}>
<Space size="middle">
@@ -323,7 +437,8 @@ export const Routes = () => {
optionType="button"
buttonStyle="solid"
/>
<Select value={pageSize.toString()} options={pageSizeOptions} onChange={onChangePageSize} className="select-rows-per-page-en"/>
<Select value={pageSize.toString()} options={pageSizeOptions}
onChange={onChangePageSize} className="select-rows-per-page-en"/>
</Space>
</Col>
<Col xs={24}
@@ -334,13 +449,15 @@ export const Routes = () => {
xxl={5} span={5}>
<Row justify="end">
<Col>
<Button type="primary" disabled={savedRoute.loading} onClick={onClickAddNewRoute}>Add Route</Button>
<Button type="primary" disabled={savedRoute.loading}
onClick={onClickAddNewRoute}>Add Route</Button>
</Col>
</Row>
</Col>
</Row>
{failed &&
<Alert message={failed.code} description={failed.message} type="error" showIcon closable/>
<Alert message={failed.message} description={failed.data ? failed.data.message : " "} type="error" showIcon
closable/>
}
<Card bodyStyle={{padding: 0}}>
<Table
@@ -360,14 +477,17 @@ export const Routes = () => {
expandable={{
expandedRowRender,
expandRowByClick: expandRowsOnClick,
onExpandedRowsChange: (r) => {setExpandRowsOnClick((!r.length))},
onExpandedRowsChange: (r) => {
setExpandRowsOnClick((!r.length))
},
}}
>
<Column title={() =>
<span>
Network Identifier
<Tooltip title="You can enable high-availability by assigning the same network identifier and network CIDR to multiple routes">
<QuestionCircleOutlined style={{ marginLeft: '0.25em', color: "gray" }}/>
<Tooltip
title="You can enable high-availability by assigning the same network identifier and network CIDR to multiple routes">
<QuestionCircleOutlined style={{marginLeft: '0.25em', color: "gray"}}/>
</Tooltip>
</span>
}
@@ -377,17 +497,21 @@ export const Routes = () => {
sorter={(a, b) => ((a as any).network_id.localeCompare((b as any).network_id))}
render={(text, record) => {
const desc = (record as RouteDataTable).description.trim()
return <Tooltip title={desc !== "" ? desc : "no description"} arrowPointAtCenter>{text}</Tooltip>
return <Tooltip title={desc !== "" ? desc : "no description"}
arrowPointAtCenter>
<Text strong>{text}</Text>
</Tooltip>
}}
/>
<Column title="Network Range" dataIndex="network" align="center"
onFilter={(value: string | number | boolean, record) => (record as any).network.includes(value)}
sorter={(a, b) => ((a as any).network.localeCompare((b as any).network))}
// defaultSortOrder='ascend'
// defaultSortOrder='ascend'
/>
<Column title="Status" dataIndex="enabled" align="center"
render={(text:Boolean) => {
return text ? <Tag color="green">enabled</Tag> : <Tag color="red">disabled</Tag>
<Column title="Route status" dataIndex="enabled" align="center"
render={(text: Boolean) => {
return text ? <Tag color="green">enabled</Tag> :
<Tag color="red">disabled</Tag>
}}
/>
<Column title="Masquerade Traffic" dataIndex="masquerade" align="center"
@@ -409,7 +533,9 @@ export const Routes = () => {
if (count > 1) {
tag = <Tag color="green">on</Tag>
}
return <div>{tag}<Divider type="vertical" /><Button type="link" onClick={() => setRouteAndView(record)}>Configure</Button></div>
return <div>{tag}<Divider type="vertical"/>
<Button type="link" onClick={() => setRouteAndView(record)}>Configure</Button>
</div>
}}
/>
</Table>

86
src/views/Settings.tsx Normal file
View File

@@ -0,0 +1,86 @@
import React, {useEffect, useState} from 'react';
import {Button, Card, Col, Form, List, message, Modal, Radio, Row, Space, Tabs, TabsProps, Typography,} from "antd";
import {Container} from "../components/Container";
import SettingsPersonal from "./SettingsPersonal";
import SettingsAccount from "./SettingsAccount";
import {useOidcUser} from "@axa-fr/react-oidc";
import {actions as userActions} from "../store/user";
import {useGetTokenSilently} from "../utils/token";
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
const {Title, Paragraph} = Typography;
const styleNotification = {marginTop: 85}
export const Settings = () => {
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const {oidcUser} = useOidcUser();
const [isAdmin, setIsAdmin] = useState(false);
const users = useSelector((state: RootState) => state.user.data)
const nsTabKey = '1'
const userItems: TabsProps['items'] = [
{
key: nsTabKey,
label: 'Personal Settings',
children: <SettingsPersonal/>,
},
]
const adminOnlyItems: TabsProps['items'] = [
{
key: '2',
label: 'Account Settings',
children: <SettingsAccount/>,
},
]
const [tabItems, setTabItems] = useState(userItems);
useEffect(() => {
if (isAdmin) {
setTabItems(userItems.concat(adminOnlyItems));
}
}, [isAdmin])
useEffect(() => {
if(users && oidcUser) {
let currentUser = users.find((user) => user.is_current)
if(currentUser) {
setIsAdmin(currentUser.role === 'admin');
}
}
}, [users, oidcUser])
useEffect(() => {
dispatch(userActions.getUsers.request({getAccessTokenSilently: getTokenSilently, payload: null}))
}, [])
const onTabClick = (key:string) => {
}
return (
<>
<Container style={{paddingTop: "40px"}}>
<Row>
<Col span={24}>
<Tabs
defaultActiveKey={nsTabKey}
items={tabItems}
onTabClick={onTabClick}
animated={{ inkBar: true, tabPane: false }}
tabPosition="top"
/>
</Col>
</Row>
</Container>
</>
)
}
export default Settings;

View File

@@ -0,0 +1,229 @@
import React, {useEffect, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {Button, Card, Col, Form, List, message, Modal, Radio, Row, Space, Typography,} from "antd";
import {useGetTokenSilently} from "../utils/token";
import {useGetGroupTagHelpers} from "../utils/groups";
import {Container} from "../components/Container";
import UserUpdate from "../components/UserUpdate";
import ExpiresInInput, {expiresInToSeconds, secondsToExpiresIn} from "./ExpiresInInput";
import {checkExpiresIn} from "../utils/common";
import {actions as accountActions} from "../store/account";
import {Account, FormAccount} from "../store/account/types";
import {ExclamationCircleOutlined, QuestionCircleFilled} from "@ant-design/icons";
const {Title, Paragraph} = Typography;
const styleNotification = {marginTop: 85}
export const Settings = () => {
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const {
} = useGetGroupTagHelpers()
const accounts = useSelector((state: RootState) => state.account.data);
const failed = useSelector((state: RootState) => state.account.failed);
const loading = useSelector((state: RootState) => state.account.loading);
const updatedAccount = useSelector((state: RootState) => state.account.updatedAccount);
const users = useSelector((state: RootState) => state.user.data);
const [formAccount, setFormAccount] = useState({} as FormAccount);
const [accountToAction, setAccountToAction] = useState({} as FormAccount);
const [formPeerExpirationEnabled, setFormPeerExpirationEnabled] = useState(true);
const [confirmModal, confirmModalContextHolder] = Modal.useModal();
const [form] = Form.useForm()
useEffect(() => {
dispatch(accountActions.getAccounts.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}, [])
useEffect(() => {
if (accounts.length < 1) {
console.error("invalid account data returned from the Management API", accounts)
return
}
let account = accounts[0]
let fAccount = {
id: account.id,
settings: account.settings,
peer_login_expiration_formatted: secondsToExpiresIn(account.settings.peer_login_expiration, ["hour", "day"]),
peer_login_expiration_enabled: account.settings.peer_login_expiration_enabled
} as FormAccount
setFormAccount(fAccount)
setFormPeerExpirationEnabled(fAccount.peer_login_expiration_enabled)
form.setFieldsValue(fAccount)
}, [accounts])
const updatingSettings = 'updating_settings';
useEffect(() => {
if (updatedAccount.loading) {
message.loading({content: 'Saving...', key: updatingSettings, duration: 0, style: styleNotification});
} else if (updatedAccount.success) {
message.success({
content: 'Account settings have been successfully saved.',
key: updatingSettings,
duration: 2,
style: styleNotification
});
dispatch(accountActions.setUpdateAccount({...updatedAccount, success: false}));
dispatch(accountActions.resetUpdateAccount(null))
let fAccount = {
id: updatedAccount.data.id,
settings: updatedAccount.data.settings,
peer_login_expiration_formatted: secondsToExpiresIn(updatedAccount.data.settings.peer_login_expiration, ["hour", "day"]),
peer_login_expiration_enabled: updatedAccount.data.settings.peer_login_expiration_enabled
} as FormAccount
setFormAccount(fAccount)
} else if (updatedAccount.error) {
let errorMsg = "Failed to update account settings"
switch (updatedAccount.error.statusCode) {
case 403:
errorMsg = "Failed to update account settings. You might not have enough permissions."
break
default:
errorMsg = updatedAccount.error.data.message ? updatedAccount.error.data.message : errorMsg
break
}
message.error({
content: errorMsg,
key: updatingSettings,
duration: 5,
style: styleNotification
});
}
}, [updatedAccount])
const handleFormSubmit = () => {
form.validateFields()
.then((values) => {
confirmSave(values)
})
.catch((errorInfo) => {
let msg = "please check the fields and try again"
if (errorInfo.errorFields) {
msg = errorInfo.errorFields[0].errors[0]
}
message.error({
content: msg,
duration: 1,
});
});
}
const createAccountToSave = (values: FormAccount): Account => {
return {
id: formAccount.id,
settings: {
peer_login_expiration: expiresInToSeconds(values.peer_login_expiration_formatted),
peer_login_expiration_enabled: values.peer_login_expiration_enabled
}
} as Account
}
const confirmSave = (newValues: FormAccount) => {
if (newValues.peer_login_expiration_enabled != formAccount.peer_login_expiration_enabled) {
let content = newValues.peer_login_expiration_enabled ? "Enabling peer expiration will cause some peers added with the SSO login to disconnect, and re-authentication will be required. Do you want to enable peer login expiration?" : "Disabling peer expiration will cause peers added with the SSO login never to expire. For security reasons, keeping peers expiring periodically is usually better. Do you want to disable peer login expiration?"
confirmModal.confirm({
icon: <ExclamationCircleOutlined/>,
title: "Before you update your account settings.",
width: 600,
content: content,
onOk() {
saveAccount(newValues)
},
onCancel() {
},
});
} else {
saveAccount(newValues)
}
}
const saveAccount = (newValues: FormAccount) => {
let accountToSave = createAccountToSave(newValues)
dispatch(accountActions.updateAccount.request({
getAccessTokenSilently: getTokenSilently,
payload: accountToSave
}))
}
return (
<>
<Container>
<Row>
<Col span={24}>
<Title level={4}>Settings</Title>
<Paragraph>Manage your account's settings</Paragraph>
<Space direction="vertical" size="large" style={{display: 'flex'}}>
<Card bodyStyle={{padding: 0}}>
<Form
name="basic"
autoComplete="off"
form={form}
onFinish={handleFormSubmit}
>
<Space direction={"vertical"}
style={{display: 'flex'}}>
<Card
title="Authentication"
loading={loading}
defaultValue={"Enabled"}
>
<Form.Item
label="Peer login expiration"
name="peer_login_expiration_enabled"
tooltip="Peer login expiration allows to periodically request re-authentication of peers that were added with the SSO login. You can disable the expiration per peer in the peers tab."
//rules={[{validator: selectValidatorEmptyStrings}]}
>
<Radio.Group
options={[{label: 'Enabled', value: true}, {
label: 'Disabled',
value: false
}]}
optionType="button"
buttonStyle="solid"
onChange={function (e) {
setFormPeerExpirationEnabled(e.target.value)
}}
/>
</Form.Item>
<Form.Item name="peer_login_expiration_formatted"
label="Peer login expires in"
tooltip="Time after which every peer added with SSO login will require re-authentication."
rules={[{validator: checkExpiresIn}]}>
<ExpiresInInput
disabled={!formPeerExpirationEnabled}
options={Array.of({key: "hour", title: "Hours"}, {
key: "day",
title: "Days"
})
}/>
</Form.Item>
<Form.Item>
<Button icon={<QuestionCircleFilled/>} type="link" target="_blank"
href="https://netbird.io/docs/how-to-guides/periodic-authentication">Learn more about login expiration</Button>
</Form.Item>
</Card>
<Form.Item style={{textAlign: 'center'}}>
<Button type="primary" htmlType="submit">
Save
</Button>
</Form.Item>
</Space>
</Form>
</Card>
</Space>
</Col>
</Row>
</Container>
<UserUpdate/>
{confirmModalContextHolder}
</>
)
}
export default Settings;

View File

@@ -0,0 +1,516 @@
import React, {useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {actions as personalAccessTokenActions} from '../store/personal-access-token';
import {actions as userActions} from '../store/user';
import {Container} from "../components/Container";
import {
Alert,
Button,
Card,
Col, Divider,
Dropdown, Form,
Input, InputNumber,
Menu,
message,
Modal,
Radio,
RadioChangeEvent,
Row,
Select,
Space,
Table,
Tag,
Typography
} from "antd";
import {filter, isNil} from "lodash"
import {copyToClipboard, timeAgo} from "../utils/common";
import {CheckOutlined, CopyOutlined, ExclamationCircleOutlined, QuestionCircleFilled} from "@ant-design/icons";
import tableSpin from "../components/Spin";
import {useGetTokenSilently} from "../utils/token";
import {usePageSizeHelpers} from "../utils/pageSize";
import {PersonalAccessToken, PersonalAccessTokenCreate, SpecificPAT} from "../store/personal-access-token/types";
import {User} from "../store/user/types";
import {useOidcUser} from "@axa-fr/react-oidc";
import SyntaxHighlighter from "react-syntax-highlighter";
const {Title, Text, Paragraph} = Typography;
const {Column} = Table;
const {confirm} = Modal;
const ExpiresInDefault = 7
interface TokenDataTable extends PersonalAccessToken {
key: string
status: String
}
export const SettingsPersonal = () => {
const {onChangePageSize,pageSizeOptions,pageSize} = usePageSizeHelpers()
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const users = useSelector((state: RootState) => state.user.data);
const personalAccessTokens = useSelector((state: RootState) => state.personalAccessToken.data);
const failed = useSelector((state: RootState) => state.personalAccessToken.failed);
const loading = useSelector((state: RootState) => state.personalAccessToken.loading);
const deletedPersonalAccessToken = useSelector((state: RootState) => state.personalAccessToken.deletedPersonalAccessToken);
const savedPersonalAccessToken = useSelector((state: RootState) => state.personalAccessToken.savedPersonalAccessToken);
const personalAccessToken = useSelector((state: RootState) => state.personalAccessToken.personalAccessToken)
const inputNameRef = useRef<any>(null)
const [textToSearch, setTextToSearch] = useState('');
const [dataTable, setDataTable] = useState([] as TokenDataTable[]);
const optionsValidAll = [ {label: 'All', value: 'all'}, {label: 'Valid', value: 'valid'}, {label: 'Expired', value: 'expired'}]
const [optionValidAll, setOptionValidAll] = useState('all');
const onChangeValidAll = ({target: {value}}: RadioChangeEvent) => {
setOptionValidAll(value)
}
const [formPersonalAccessToken, setFormPersonalAccessToken] = useState({} as PersonalAccessTokenCreate)
const [form] = Form.useForm()
const {oidcUser} = useOidcUser();
const [personalAccessTokenToDelete, setPersonalAccessTokenToDelete] = useState(null as PersonalAccessToken | null);
const [addTokenModalOpen, setNewTokenModalOpen] = useState(false);
const [showPlainToken, setShowPlainToken] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
const [plainToken, setPlainToken] = useState("")
const [confirmModal, confirmModalContextHolder] = Modal.useModal();
const styleNotification = {marginTop: 85}
const itemsMenuAction = [
{
key: "delete",
label: (<Button type="text" onClick={() => showConfirmDelete()}>Delete</Button>)
},
]
const actionsMenu = (<Menu items={itemsMenuAction}></Menu>)
useEffect(() => {
if (!personalAccessToken) return
setFormPersonalAccessToken(personalAccessToken)
form.setFieldsValue(personalAccessToken)
}, [personalAccessToken])
useEffect(() => {
dispatch(userActions.getUsers.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}, [])
const onChange = (data: any) => {
setFormPersonalAccessToken({...formPersonalAccessToken, ...data})
}
useEffect(() => {
if(oidcUser) {
let currentUserId = users.find((user) => user.is_current) ? users.find((user) => user.is_current)?.id : oidcUser.sub;
dispatch(personalAccessTokenActions.getPersonalAccessTokens.request({
getAccessTokenSilently: getTokenSilently,
payload: currentUserId}));
}
}, [oidcUser, users])
useEffect(() => {
dispatch(userActions.getUsers.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}, [])
useEffect(() => {
setDataTable(filterDataTable(transformTokenTable(personalAccessTokens, users)))
}, [personalAccessTokens, textToSearch, users, optionValidAll])
const deleteKey = 'deleting';
useEffect(() => {
if (deletedPersonalAccessToken.loading) {
message.loading({content: 'Deleting...', key: deleteKey, style: styleNotification});
} else if (deletedPersonalAccessToken.success) {
message.success({
content: 'Personal access token has been successfully removed.',
key: deleteKey,
duration: 2,
style: styleNotification
});
dispatch(personalAccessTokenActions.setDeletePersonalAccessToken({...deletedPersonalAccessToken, success: false}))
dispatch(personalAccessTokenActions.resetDeletedPersonalAccessToken(null))
} else if (deletedPersonalAccessToken.error) {
message.error({
content: 'Failed to delete personal access token. You might not have enough permissions.',
key: deleteKey,
duration: 2,
style: styleNotification
});
dispatch(personalAccessTokenActions.setDeletePersonalAccessToken({...deletedPersonalAccessToken, error: null}))
dispatch(personalAccessTokenActions.resetDeletedPersonalAccessToken(null))
}
}, [deletedPersonalAccessToken])
const createKey = 'saving';
useEffect(() => {
if (savedPersonalAccessToken.loading) {
message.loading({content: 'Saving...', key: createKey, duration: 0, style: styleNotification});
} else if (savedPersonalAccessToken.success) {
message.destroy(createKey)
setPlainToken(savedPersonalAccessToken.data.plain_token)
setShowPlainToken(true)
} else if (savedPersonalAccessToken.error) {
message.error({
content: 'Failed to create personal access token. You might not have enough permissions.',
key: createKey,
duration: 2,
style: styleNotification
});
setNewTokenModalOpen(false)
setShowPlainToken(false)
setTokenCopied(false)
dispatch(personalAccessTokenActions.setSavedPersonalAccessToken({...savedPersonalAccessToken, error: null}));
dispatch(personalAccessTokenActions.resetSavedPersonalAccessToken(null))
}
}, [savedPersonalAccessToken])
const transformTokenTable = (d: PersonalAccessToken[], u: User[]): TokenDataTable[] => {
if(!d) {
return []
}
return d.map(p => ({
key: p.id,
status: Date.parse(p.expiration_date) > Date.now() ? "valid" : "expired",
...p} as TokenDataTable))
}
const filterDataTable = (f: TokenDataTable[]): TokenDataTable[] => {
const t = textToSearch.toLowerCase().trim()
switch (optionValidAll) {
case "valid":
f = filter(f, (_f: TokenDataTable) => _f.status === "valid")
break
case "expired":
f = filter(f, (_f: TokenDataTable) => _f.status === "expired")
break
default:
break
}
f = filter(f, (_f: TokenDataTable) =>
(_f.name.toLowerCase().includes(t) || _f.status.toLowerCase().includes(t) || t === "")
) as TokenDataTable[]
return f
}
const onChangeTextToSearch = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setTextToSearch(e.target.value)
};
const searchDataTable = () => {
setDataTable(filterDataTable(transformTokenTable(personalAccessTokens, users)))
}
const showConfirmDelete = () => {
let name = personalAccessTokenToDelete ? personalAccessTokenToDelete.name : '';
confirmModal.confirm({
icon: <ExclamationCircleOutlined/>,
title: "Delete token \"" + name + "\"",
width: 600,
content: <Space direction="vertical" size="small">
<Paragraph>Are you sure you want to delete this token?</Paragraph>
</Space>,
onOk() {
let currentUserId = users.find((user) => user.is_current) ? users.find((user) => user.is_current)?.id : oidcUser.sub;
dispatch(personalAccessTokenActions.deletePersonalAccessToken.request({
getAccessTokenSilently: getTokenSilently,
payload: {
user_id: currentUserId,
id: personalAccessTokenToDelete ? personalAccessTokenToDelete.id : null,
name: personalAccessTokenToDelete ? personalAccessTokenToDelete.name : null,
} as SpecificPAT
}));
},
onCancel() {
setPersonalAccessTokenToDelete(null);
},
});
}
const onClickAddNewPersonalAccessToken = () => {
setNewTokenModalOpen(true)
dispatch(personalAccessTokenActions.setNewPersonalAccessTokenVisible(true));
dispatch(personalAccessTokenActions.setPersonalAccessToken({
user_id: "",
name: "",
expires_in: 7
} as PersonalAccessTokenCreate))
}
const onCancel = () => {
setNewTokenModalOpen(false)
setShowPlainToken(false)
setTokenCopied(false)
if (savedPersonalAccessToken.loading) return
dispatch(personalAccessTokenActions.setPersonalAccessToken({
user_id: "",
name: "",
expires_in: 0
} as PersonalAccessTokenCreate))
setFormPersonalAccessToken({} as PersonalAccessTokenCreate)
setVisibleNewSetupKey(false)
dispatch(personalAccessTokenActions.setNewPersonalAccessTokenVisible(false));
dispatch(personalAccessTokenActions.setSavedPersonalAccessToken({...savedPersonalAccessToken, success: false}));
dispatch(personalAccessTokenActions.resetSavedPersonalAccessToken(null))
}
const setVisibleNewSetupKey = (status: boolean) => {
dispatch(personalAccessTokenActions.setNewPersonalAccessTokenVisible(status));
}
const createPersonalAccessTokenToSave = (): PersonalAccessTokenCreate => {
let currentUserId = users.find((user) => user.is_current) ? users.find((user) => user.is_current)?.id : oidcUser.sub;
return {
user_id: currentUserId,
name: formPersonalAccessToken.name,
expires_in: formPersonalAccessToken.expires_in,
} as PersonalAccessTokenCreate
}
const handleFormSubmit = () => {
form.validateFields()
.then((values) => {
let personalAccessTokenToSave = createPersonalAccessTokenToSave()
dispatch(personalAccessTokenActions.savePersonalAccessToken.request({
getAccessTokenSilently: getTokenSilently,
payload: personalAccessTokenToSave
}))
})
.catch((errorInfo) => {
console.log('errorInfo', errorInfo)
});
};
const onCopyClick = (text: string, copied: boolean) => {
copyToClipboard(text)
setTokenCopied(true)
if (copied) {
setTimeout(() => {
onCopyClick(text, false)
}, 2000)
}
}
return (
<>
<Container>
<Row>
<Col span={24}>
<Title level={4}>Personal Access Tokens</Title>
<Paragraph>Personal Access Tokens can be used to authenticate against NetBird's Public API.</Paragraph>
<Space direction="vertical" size="large" style={{display: 'flex'}}>
<Row gutter={[16, 24]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} xxl={8} span={8}>
<Input allowClear value={textToSearch} onPressEnter={searchDataTable}
placeholder="Search..." onChange={onChangeTextToSearch}/>
</Col>
<Col xs={24} sm={24} md={11} lg={11} xl={11} xxl={11} span={11}>
<Space size="middle">
<Radio.Group
options={optionsValidAll}
onChange={onChangeValidAll}
value={optionValidAll}
optionType="button"
buttonStyle="solid"
/>
<Select value={pageSize.toString()} options={pageSizeOptions}
onChange={onChangePageSize} className="select-rows-per-page-en"/>
</Space>
</Col>
<Col xs={24}
sm={24}
md={5}
lg={5}
xl={5}
xxl={5} span={5}>
<Row justify="end">
<Col>
<Button type="primary" onClick={onClickAddNewPersonalAccessToken}>Add Token</Button>
</Col>
</Row>
</Col>
</Row>
{failed &&
<Alert message={failed.message} description={failed.data ? failed.data.message : " "} type="error" showIcon
closable/>
}
<Card bodyStyle={{padding: 0}}>
<Table
pagination={{
pageSize,
showSizeChanger: false,
showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} personal access tokens`)
}}
className="card-table"
showSorterTooltip={false}
scroll={{x: true}}
loading={tableSpin(loading)}
dataSource={dataTable}>
<Column title="Name" dataIndex="name"
onFilter={(value: string | number | boolean, record) => (record as any).name.includes(value)}
sorter={(a, b) => ((a as any).name.localeCompare((b as any).name))}
render={(text, record, index) => {
return <Text strong>{text}</Text>
}}
/>
<Column title="Last Used" dataIndex="last_used"
sorter={(a, b) => ((a as any).last_used.localeCompare((b as any).last_used))}
render={(text, record, index) => {
return isNil((record as TokenDataTable).last_used) ? "never" : timeAgo(text)
}}
/>
<Column title="Created" dataIndex="created_at"
sorter={(a, b) => ((a as any).created_at.localeCompare((b as any).created_at))}
render={(text, record, index) => {
return <Text>{timeAgo(text)}</Text>
}}
defaultSortOrder='descend'
/>
<Column title="Status" dataIndex="status"
sorter={(a, b) => ((a as any).status.localeCompare((b as any).status))}
render={(text, record, index) => {
return (text === 'valid') ? <Tag color="green">{text}</Tag> :
<Tag color="red">{text}</Tag>
}}
/>
<Column title="Expires" dataIndex="expiration_date"
sorter={(a, b) => ((a as any).expiration_date.localeCompare((b as any).expiration_date))}
render={(text, record, index) => {
return timeAgo(text)
}}
/>
<Column title="" align="center"
render={(text, record, index) => {
return (
<Dropdown.Button type="text" overlay={actionsMenu}
trigger={["click"]}
onOpenChange={visible => {
if (visible) setPersonalAccessTokenToDelete(record as PersonalAccessToken)
}}></Dropdown.Button>)
}}
/>
</Table>
</Card>
</Space>
</Col>
</Row>
</Container>
<Modal
open={addTokenModalOpen}
onCancel={onCancel}
footer={
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button disabled={savedPersonalAccessToken.loading} onClick={onCancel}>{showPlainToken ? "Close" : "Cancel"}</Button>
<Button type="primary" disabled={showPlainToken}
onClick={handleFormSubmit}>{"Create"}</Button>
</Space>
}
width={780}
>
<Container style={{textAlign: "center"}}>
<Paragraph
style={{textAlign: "center", whiteSpace: "pre-line", fontSize: "2em"}}>
{showPlainToken ? "Token created successfully!" : "Create new Personal Access Token"}
</Paragraph>
<Paragraph type={"secondary"}
style={{
textAlign: "center",
whiteSpace: "pre-line",
marginTop: "-15px",
paddingBottom: "25px",
}}>
{showPlainToken ? "You will only see this token once," + "\n" + "so copy it and store it in a secure location." : "This token can be used to authenticate against NetBird's Public API."}
</Paragraph>
{!showPlainToken && <Form layout="vertical" hideRequiredMark form={form} onValuesChange={onChange}
initialValues={{
expires_in: ExpiresInDefault,
}}
style={{paddingLeft: "80px", paddingRight: "80px"}}
>
<Row gutter={16}>
<Col span={24}>
<Divider style={{marginTop: "0px"}}></Divider>
<Row align="top">
<Col flex="auto">
<Form.Item
name="name"
label={
<Text style={{color: "gray"}}><b style={{color: "black"}}>Name</b> (Set a name to identify the token.)</Text>
}
rules={[{
required: true,
message: 'Please add a name for this personal access token',
whitespace: true
}]}
>
<Input
placeholder={""}
ref={inputNameRef}
autoComplete="off"/>
</Form.Item>
</Col>
</Row>
<Divider style={{marginTop: "0px"}}></Divider>
</Col>
<Col span={24} style={{textAlign: "left"}}>
<Form.Item
name="expires_in"
label={
<Text style={{color: "gray"}}><b style={{color: "black"}}>Expires</b> (Set the amount of days the token should be valid.)</Text>
}
rules={[{
type: 'number',
min: 1,
max: 356,
message: 'The expiration should be set between 1 and 365 days'
}]}>
<InputNumber/>
</Form.Item>
</Col>
<Col span={24}>
<Divider style={{marginTop: "0px"}}></Divider>
<Button icon={<QuestionCircleFilled/>} type="link" target="_blank" disabled={true}
href="https://netbird.io/docs/overview/personal-access-tokens">Learn more about personal access tokens</Button>
</Col>
</Row>
</Form>}
{showPlainToken && <Space className="nb-code" direction="vertical" size="middle">
<Row>
<>
<Space className="nb-code" direction="vertical" size="small" style={{display: "flex", fontSize: ".85em"}}>
<SyntaxHighlighter language="bash">
{plainToken}
</SyntaxHighlighter>
</Space>
{ !tokenCopied ? (
<Button type="text" size="middle" className="btn-copy-code" icon={<CopyOutlined/>}
style={{color: "rgb(107, 114, 128)", marginTop: "-1px"}}
onClick={() => onCopyClick(plainToken, true)}/>
): (
<Button type="text" size="middle" className="btn-copy-code" icon={<CheckOutlined/>}
style={{color: "green", marginTop: "-1px"}}/>
)}
</>
</Row>
</Space>}
</Container>
</Modal>
{confirmModalContextHolder}
</>
)
}
export default SettingsPersonal;

View File

@@ -3,7 +3,6 @@ import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {actions as setupKeyActions} from '../store/setup-key';
import {Container} from "../components/Container";
import {useOidcAccessToken} from '@axa-fr/react-oidc';
import {
Alert,
Button,
@@ -13,7 +12,8 @@ import {
Input,
Menu,
message,
Modal, Popover,
Modal,
Popover,
Radio,
RadioChangeEvent,
Row,
@@ -33,6 +33,8 @@ import tableSpin from "../components/Spin";
import {actions as groupActions} from "../store/group";
import {Group} from "../store/group/types";
import {TooltipPlacement} from "antd/es/tooltip";
import {useGetTokenSilently} from "../utils/token";
import {usePageSizeHelpers} from "../utils/pageSize";
const {Title, Text, Paragraph} = Typography;
const {Column} = Table;
@@ -44,7 +46,8 @@ interface SetupKeyDataTable extends SetupKey {
}
export const SetupKeys = () => {
const {accessToken} = useOidcAccessToken()
const {onChangePageSize,pageSizeOptions,pageSize} = usePageSizeHelpers()
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const setupKeys = useSelector((state: RootState) => state.setupKey.data);
@@ -56,17 +59,13 @@ export const SetupKeys = () => {
const [textToSearch, setTextToSearch] = useState('');
const [optionValidAll, setOptionValidAll] = useState('valid');
const [pageSize, setPageSize] = useState(10);
const [dataTable, setDataTable] = useState([] as SetupKeyDataTable[]);
const [setupKeyToAction, setSetupKeyToAction] = useState(null as SetupKeyDataTable | null);
const setupNewKeyVisible = useSelector((state: RootState) => state.setupKey.setupNewKeyVisible)
const [groupPopupVisible,setGroupPopupVisible] = useState(false as boolean|undefined)
const styleNotification = {marginTop: 85}
const pageSizeOptions = [
{label: "5", value: "5"},
{label: "10", value: "10"},
{label: "15", value: "15"}
]
const optionsValidAll = [{label: 'Valid', value: 'valid'}, {label: 'All', value: 'all'}]
@@ -82,14 +81,15 @@ export const SetupKeys = () => {
]
const actionsMenu = (<Menu items={itemsMenuAction}></Menu>)
const [confirmModal, confirmModalContextHolder] = Modal.useModal();
const transformDataTable = (d: SetupKey[]): SetupKeyDataTable[] => {
return d.map(p => ({...p, groupsCount: p.auto_groups ? p.auto_groups.length : 0} as SetupKeyDataTable))
}
useEffect(() => {
dispatch(setupKeyActions.getSetupKeys.request({getAccessTokenSilently: accessToken, payload: null}));
dispatch(groupActions.getGroups.request({getAccessTokenSilently: accessToken, payload: null}));
dispatch(setupKeyActions.getSetupKeys.request({getAccessTokenSilently: getTokenSilently, payload: null}));
dispatch(groupActions.getGroups.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}, [])
useEffect(() => {
@@ -176,51 +176,18 @@ export const SetupKeys = () => {
setOptionValidAll(value)
}
const onChangePageSize = (value: string) => {
setPageSize(parseInt(value.toString()))
}
const showConfirmDelete = () => {
confirm({
icon: <ExclamationCircleOutlined/>,
width: 600,
content: <Space direction="vertical" size="small">
{setupKeyToAction &&
<>
<Title level={5}>Delete setupKey "{setupKeyToAction ? setupKeyToAction.name : ''}"</Title>
<Paragraph>Are you sure you want to delete key?</Paragraph>
</>
}
</Space>,
okType: 'danger',
onOk() {
dispatch(setupKeyActions.deleteSetupKey.request({
getAccessTokenSilently: accessToken,
payload: setupKeyToAction ? setupKeyToAction.id : ''
}));
},
onCancel() {
setSetupKeyToAction(null);
},
});
}
const showConfirmRevoke = () => {
confirm({
let name = setupKeyToAction ? setupKeyToAction.name : ''
confirmModal.confirm({
icon: <ExclamationCircleOutlined/>,
title: "Revoke setupKey \"" + name + "\"",
width: 600,
content: <Space direction="vertical" size="small">
{setupKeyToAction &&
<>
<Title level={5}>Revoke setupKey "{setupKeyToAction ? setupKeyToAction.name : ''}"</Title>
<Paragraph>Are you sure you want to revoke key?</Paragraph>
</>
}
<Paragraph>Are you sure you want to revoke key?</Paragraph>
</Space>,
okType: 'danger',
onOk() {
dispatch(setupKeyActions.saveSetupKey.request({
getAccessTokenSilently: accessToken,
getAccessTokenSilently: getTokenSilently,
payload: {
id: setupKeyToAction ? setupKeyToAction.id : null,
revoked: true,
@@ -240,7 +207,7 @@ export const SetupKeys = () => {
dispatch(setupKeyActions.setSetupNewKeyVisible(true));
dispatch(setupKeyActions.setSetupKey({
name: "",
type: "reusable",
type: "one-off",
auto_groups: autoGroups
} as SetupKey))
}
@@ -259,6 +226,7 @@ export const SetupKeys = () => {
valid: key?.valid,
auto_groups: key?.auto_groups,
last_used: key?.last_used,
usage_limit: key?.usage_limit
} as SetupKey))
}
@@ -276,9 +244,24 @@ export const SetupKeys = () => {
valid: setupKeyToAction?.valid,
auto_groups: setupKeyToAction?.auto_groups,
last_used: setupKeyToAction?.last_used,
usage_limit: setupKeyToAction?.usage_limit
} as SetupKey))
}
useEffect(() => {
if (setupNewKeyVisible) {
setGroupPopupVisible(false)
}
}, [setupNewKeyVisible])
const onPopoverVisibleChange = (b:boolean) => {
if (setupNewKeyVisible) {
setGroupPopupVisible(false)
} else {
setGroupPopupVisible(undefined)
}
}
const renderPopoverGroups = (label: string, rowGroups: string[] | string[] | null, setupKeyToAction: SetupKeyDataTable) => {
let groupsMap = new Map<string, Group>();
@@ -318,7 +301,11 @@ export const SetupKeys = () => {
}
return (
<Popover placement={popoverPlacement as TooltipPlacement} key={setupKeyToAction.key} content={mainContent}
<Popover placement={popoverPlacement as TooltipPlacement}
key={setupKeyToAction.key}
onOpenChange={onPopoverVisibleChange}
open={groupPopupVisible}
content={mainContent}
title={null}>
{btn}
</Popover>
@@ -334,7 +321,7 @@ export const SetupKeys = () => {
const autoGroups : string[] = []
dispatch(setupKeyActions.setSetupKey({
name: "",
type: "reusable",
type: "one-off",
auto_groups: autoGroups
} as SetupKey))
dispatch(setupKeyActions.setSetupNewKeyVisible(false))
@@ -382,7 +369,7 @@ export const SetupKeys = () => {
</Col>
</Row>
{failed &&
<Alert message={failed.code} description={failed.message} type="error" showIcon
<Alert message={failed.message} description={failed.data ? failed.data.message : " "} type="error" showIcon
closable/>
}
<Card bodyStyle={{padding: 0}}>
@@ -403,7 +390,8 @@ export const SetupKeys = () => {
render={(text, record, index) => {
return <Button type="text"
onClick={() => setKeyAndView(record as SetupKeyDataTable)}
className="tooltip-label">{text}</Button>
className="tooltip-label"> <Text strong>{text}</Text>
</Button>
}}
defaultSortOrder='ascend'
/>
@@ -429,8 +417,10 @@ export const SetupKeys = () => {
onFilter={(value: string | number | boolean, record) => (record as any).key.includes(value)}
sorter={(a, b) => ((a as any).key.localeCompare((b as any).key))}
render={(text, record, index) => {
const body = <Text>{text}</Text>
return <ButtonCopyMessage keyMessage={(record as SetupKeyDataTable).key}
text={text} messageText={`Key copied!`}
toCopy={text}
body={body} messageText={"Key copied"}
styleNotification={{}}/>
}}
/>
@@ -456,7 +446,7 @@ export const SetupKeys = () => {
return !(record as SetupKeyDataTable).revoked ? (
<Dropdown.Button type="text" overlay={actionsMenu}
trigger={["click"]}
onVisibleChange={visible => {
onOpenChange={visible => {
if (visible) setSetupKeyToAction(record as SetupKeyDataTable)
}}></Dropdown.Button>) : <></>
}}
@@ -469,6 +459,7 @@ export const SetupKeys = () => {
</Container>
<SetupKeyNew/>
{confirmModalContextHolder}
</>
)
}

View File

@@ -1,51 +1,82 @@
import React, {useEffect, useState} from 'react';
import React, {useEffect, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import { RootState } from "typesafe-actions";
import { actions as userActions } from '../store/user';
import {RootState} from "typesafe-actions";
import {actions as userActions} from '../store/user';
import {Container} from "../components/Container";
import {useOidcAccessToken} from '@axa-fr/react-oidc';
import {
Col,
Row,
Typography,
Table,
Alert,
Button,
Card,
Space, Input, Select, Alert,
Col,
Dropdown,
Input,
Menu,
message,
Popover,
Row,
Select,
Space,
Table,
Tag,
Typography,
} from "antd";
import { User } from "../store/user/types";
import {User} from "../store/user/types";
import {filter} from "lodash";
import tableSpin from "../components/Spin";
import {useGetTokenSilently} from "../utils/token";
import UserUpdate from "../components/UserUpdate";
import {actions as groupActions} from "../store/group";
import {Group} from "../store/group/types";
import {TooltipPlacement} from "antd/es/tooltip";
import {isLocalDev, isNetBirdHosted} from "../utils/common";
import {usePageSizeHelpers} from "../utils/pageSize";
const { Title, Paragraph } = Typography;
const { Column } = Table;
const {Title, Paragraph, Text} = Typography;
const {Column} = Table;
interface UserDataTable extends User {
key: string
}
const styleNotification = {marginTop: 85}
export const Users = () => {
const {accessToken} = useOidcAccessToken()
const {onChangePageSize,pageSizeOptions,pageSize} = usePageSizeHelpers()
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const groups = useSelector((state: RootState) => state.group.data)
const users = useSelector((state: RootState) => state.user.data);
const failed = useSelector((state: RootState) => state.user.failed);
const loading = useSelector((state: RootState) => state.user.loading);
const updateUserDrawerVisible = useSelector((state: RootState) => state.user.updateUserDrawerVisible)
const savedUser = useSelector((state: RootState) => state.user.savedUser)
const [groupPopupVisible, setGroupPopupVisible] = useState(false as boolean | undefined)
const [userToAction, setUserToAction] = useState(null as UserDataTable | null);
const [textToSearch, setTextToSearch] = useState('');
const [pageSize, setPageSize] = useState(5);
const [dataTable, setDataTable] = useState([] as UserDataTable[]);
const pageSizeOptions = [
{label: "5", value: "5"},
{label: "10", value: "10"},
{label: "15", value: "15"}
]
const transformDataTable = (d:User[]):UserDataTable[] => {
return d.map(p => ({ key: p.id, ...p } as UserDataTable))
// setUserAndView makes the UserUpdate drawer visible (right side) and sets the user object
const setUserAndView = (user: User) => {
dispatch(userActions.setUpdateUserDrawerVisible(true));
dispatch(userActions.setUser({
id: user.id,
email: user.email,
role: user.role,
auto_groups: user.auto_groups ? user.auto_groups : [],
name: user.name,
is_current: user.is_current
} as User));
}
const transformDataTable = (d: User[]): UserDataTable[] => {
return d.map(p => ({key: p.id, ...p} as UserDataTable))
}
useEffect(() => {
dispatch(userActions.getUsers.request({getAccessTokenSilently:accessToken,payload: null}));
dispatch(userActions.getUsers.request({getAccessTokenSilently: getTokenSilently, payload: null}));
dispatch(groupActions.getGroups.request({getAccessTokenSilently: getTokenSilently, payload: null}));
}, [])
useEffect(() => {
setDataTable(transformDataTable(users))
@@ -55,10 +86,10 @@ export const Users = () => {
setDataTable(transformDataTable(filterDataTable()))
}, [textToSearch])
const filterDataTable = ():User[] => {
const filterDataTable = (): User[] => {
const t = textToSearch.toLowerCase().trim()
let f:User[] = filter(users, (f:User) =>
((f.email || f.id).toLowerCase().includes(t) || f.name.includes(t) || f.role.includes(t) || t === "")
let f: User[] = filter(users, (f: User) =>
((f.email || f.id).toLowerCase().includes(t) || f.name.toLowerCase().includes(t) || f.role.includes(t) || t === "")
) as User[]
return f
}
@@ -72,58 +103,249 @@ export const Users = () => {
setDataTable(transformDataTable(data))
}
const onChangePageSize = (value: string) => {
setPageSize(parseInt(value.toString()))
const onClickEdit = () => {
dispatch(userActions.setUpdateUserDrawerVisible(true));
dispatch(userActions.setUser({
id: userToAction?.id,
email: userToAction?.email,
auto_groups: userToAction?.auto_groups ? userToAction?.auto_groups : [],
name: userToAction?.name,
role: userToAction?.role,
} as User));
}
return(
<Container style={{paddingTop: "40px"}}>
<Row>
<Col span={24}>
<Title level={4}>Users</Title>
<Paragraph>A list of all Users</Paragraph>
<Space direction="vertical" size="large" style={{ display: 'flex' }}>
<Row gutter={[16, 24]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} xxl={8} span={8}>
<Input allowClear value={textToSearch} onPressEnter={searchDataTable} placeholder="Search..." onChange={onChangeTextToSearch} />
</Col>
<Col xs={24} sm={24} md={11} lg={11} xl={11} xxl={11} span={11}>
<Space size="middle">
<Select value={pageSize.toString()} options={pageSizeOptions} onChange={onChangePageSize} className="select-rows-per-page-en"/>
</Space>
</Col>
</Row>
{failed &&
<Alert message={failed.code} description={failed.message} type="error" showIcon closable/>
}
<Card bodyStyle={{padding: 0}}>
<Table
pagination={{pageSize, showSizeChanger: false, showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} users`)}}
className="card-table"
showSorterTooltip={false}
scroll={{x: true}}
loading={tableSpin(loading)}
dataSource={dataTable}>
<Column title="Email" dataIndex="email"
onFilter={(value: string | number | boolean, record) => (record as any).email.includes(value)}
sorter={(a, b) => ((a as any).email.localeCompare((b as any).email))}
defaultSortOrder='ascend'
render={(text:string | null, record, index) => {
return (text && text.trim() !== "") ? text : (record as User).id
}}
/>
<Column title="Name" dataIndex="name"
onFilter={(value: string | number | boolean, record) => (record as any).name.includes(value)}
sorter={(a, b) => ((a as any).name.localeCompare((b as any).name))} />
<Column title="Role" dataIndex="role"
onFilter={(value: string | number | boolean, record) => (record as any).role.includes(value)}
sorter={(a, b) => ((a as any).role.localeCompare((b as any).role))} />
</Table>
</Card>
</Space>
</Col>
</Row>
</Container>
const onClickInviteUser = () => {
const autoGroups : string[] = []
dispatch(userActions.setUpdateUserDrawerVisible(true));
dispatch(userActions.setUser({
id: "",
email: "",
auto_groups: autoGroups,
name: "",
role: "user",
} as User));
}
const renderPopoverGroups = (label: string, rowGroups: string[] | string[] | null, userToAction: UserDataTable) => {
let groupsMap = new Map<string, Group>();
groups.forEach(g => {
groupsMap.set(g.id!, g)
})
let displayGroups: Group[] = []
if (rowGroups) {
displayGroups = rowGroups.filter(g => groupsMap.get(g)).map(g => groupsMap.get(g)!)
}
let btn = <Button type="link" onClick={() => setUserAndView(userToAction)}>{displayGroups.length}</Button>
if (!displayGroups || displayGroups!.length < 1) {
return btn
}
const content = displayGroups?.map((g, i) => {
const _g = g as Group
const peersCount = ` - ${_g.peers_count || 0} ${(!_g.peers_count || parseInt(_g.peers_count) !== 1) ? 'peers' : 'peer'} `
return (
<div key={i}>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{_g.name}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</div>
)
})
const mainContent = (<Space direction="vertical">{content}</Space>)
let popoverPlacement = "top"
if (content && content.length > 5) {
popoverPlacement = "rightTop"
}
return (
<Popover placement={popoverPlacement as TooltipPlacement}
key={userToAction.id}
onOpenChange={onPopoverVisibleChange}
open={groupPopupVisible}
content={mainContent}
title={null}>
{btn}
</Popover>
)
}
useEffect(() => {
if (updateUserDrawerVisible) {
setGroupPopupVisible(false)
}
}, [updateUserDrawerVisible])
const createKey = 'saving';
useEffect(() => {
if (savedUser.loading) {
message.loading({content: 'Saving...', key: createKey, duration: 0, style: styleNotification});
} else if (savedUser.success) {
message.success({
content: 'User has been successfully saved.',
key: createKey,
duration: 2,
style: styleNotification
});
dispatch(userActions.setUpdateUserDrawerVisible(false));
dispatch(userActions.setSavedUser({...savedUser, success: false}));
dispatch(userActions.resetSavedUser(null))
} else if (savedUser.error) {
let errorMsg = "Failed to update user"
switch (savedUser.error.statusCode) {
case 412:
errorMsg = savedUser.error.data
break
case 403:
errorMsg = "Failed to update user. You might not have enough permissions."
break
}
message.error({
content: errorMsg,
key: createKey,
duration: 5,
style: styleNotification
});
dispatch(userActions.setSavedUser({...savedUser, error: null}));
dispatch(userActions.resetSavedUser(null))
}
}, [savedUser])
const onPopoverVisibleChange = (b: boolean) => {
if (updateUserDrawerVisible) {
setGroupPopupVisible(false)
} else {
setGroupPopupVisible(undefined)
}
}
const itemsMenuAction = [
{
key: "edit",
label: (<Button type="text" onClick={() => onClickEdit()}>View</Button>)
},
]
const actionsMenu = (<Menu items={itemsMenuAction}></Menu>)
return (
<>
<Container style={{paddingTop: "40px"}}>
<Row>
<Col span={24}>
<Title level={4}>Users</Title>
<Paragraph>A list of all users{(window.location.hostname == "app.netbird.io") ? ". Users with an email from the same private organization domain will automatically join when they sign in for the first time." : ""}</Paragraph>
<Space direction="vertical" size="large" style={{display: 'flex'}}>
<Row gutter={[16, 24]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} xxl={8} span={8}>
<Input allowClear value={textToSearch} onPressEnter={searchDataTable}
placeholder="Search..." onChange={onChangeTextToSearch}/>
</Col>
<Col xs={24} sm={24} md={11} lg={11} xl={11} xxl={11} span={11}>
<Space size="middle">
<Select value={pageSize.toString()} options={pageSizeOptions}
onChange={onChangePageSize} className="select-rows-per-page-en"/>
</Space>
</Col>
<Col xs={24}
sm={24}
md={5}
lg={5}
xl={5}
xxl={5} span={5}>
{(isNetBirdHosted() || isLocalDev()) &&
<Row justify="end">
<Col>
<Button type="primary" onClick={onClickInviteUser}>Invite User</Button>
</Col>
</Row>}
</Col>
</Row>
{failed &&
<Alert message={failed.message} description={failed.data ? failed.data.message : " "} type="error" showIcon
closable/>
}
<Card bodyStyle={{padding: 0}}>
<Table
pagination={{
pageSize,
showSizeChanger: false,
showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} users`)
}}
className="card-table"
showSorterTooltip={false}
scroll={{x: true}}
loading={tableSpin(loading)}
dataSource={dataTable}>
<Column title="Email" dataIndex="email"
onFilter={(value: string | number | boolean, record) => (record as any).email.includes(value)}
sorter={(a, b) => ((a as any).email.localeCompare((b as any).email))}
defaultSortOrder='ascend'
render={(text, record, index) => {
const btn = <Button type="text"
onClick={() => setUserAndView(record as UserDataTable)}
className="tooltip-label">
<Text
strong>{(text && text.trim() !== "") ? text : (record as User).id}</Text>
</Button>
if ((record as User).is_current) {
return <div>{btn}
<Tag color="blue">me</Tag>
</div>
}
return btn
}}
/>
<Column title="Name" dataIndex="name"
onFilter={(value: string | number | boolean, record) => (record as any).name.includes(value)}
sorter={(a, b) => ((a as any).name.localeCompare((b as any).name))}/>
<Column title="Status" dataIndex="status"
align="center"
onFilter={(value: string | number | boolean, record) => (record as any).status.includes(value)}
sorter={(a, b) => ((a as any).status.localeCompare((b as any).status))}
render={(text, record, index) => {
if (text == "active") {
return <Tag color="green">{text}</Tag>
} else if (text === "invited"){
return <Tag color="gold">{text}</Tag>
}
return <Tag color="red">{text}</Tag>
}}
/>
<Column title="Groups" dataIndex="groupsCount" align="center"
render={(text, record: UserDataTable, index) => {
return renderPopoverGroups(text, record.auto_groups, record)
}}
/>
<Column title="Role" dataIndex="role"
onFilter={(value: string | number | boolean, record) => (record as any).role.includes(value)}
sorter={(a, b) => ((a as any).role.localeCompare((b as any).role))}/>
<Column title="" align="center" width="30px"
render={(text, record, index) => {
return (
<Dropdown.Button type="text" overlay={actionsMenu}
trigger={["click"]}
onVisibleChange={visible => {
if (visible) setUserToAction(record as UserDataTable)
}}></Dropdown.Button>)
}}
/>
</Table>
</Card>
</Space>
</Col>
</Row>
</Container>
<UserUpdate/>
</>
)
}