Compare commits

...

42 Commits

Author SHA1 Message Date
Maycon Santos
58cec8fcd1 ignore mappin spelling (#408)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-08-13 15:59:38 +02:00
Eduard Gert
d34ae9beb2 Sync changes with netbird cloud (#407)
* Update axa oidc library and package.json

* Update ACL port state to show correct value

* Filter user groups by unique groups only

* Add peer multiselect, optimize dropdown performance for peer selection, remove 'all' group from some dropdowns, various ui / ux optimizations

* Add peer multiselect, optimize dropdown performance for peer selection, remove 'all' group from some dropdowns, various ui / ux optimizations
2024-08-13 15:51:22 +02:00
Eduard Gert
650496f670 Include all settings in put request to prevent overwrite (#405)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-07-31 18:48:59 +02:00
Tom Hubrecht
121778c4a6 Fix package-lock.json (#401) 2024-07-12 10:35:31 +02:00
juliaroesschen
d4102c5d04 fix typo in route update modal (#397)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-07-04 15:05:57 +02:00
pascal-fischer
e78c35bdbe Fix DNS modal to allow one char domains (#393)
* update regex to allow one char domains in DNS routing modal

* update regex
2024-07-04 10:50:37 +02:00
juliaroesschen
6ebee98695 Fix typo in Network Routes dialogue (#395) 2024-07-04 10:48:49 +02:00
juliaroesschen
f4b28d5f40 Fix typo in routes modal 2024-06-28 11:38:39 +02:00
Eduard Gert
b4b6d9295b Add DNS routes (#390)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-06-17 09:32:55 +02:00
Maycon Santos
4898742ee9 Fix http://localhost:3000/ url validation case (#388)
* Fix http://localhost:3000/ url validation case

* adjust min regex occurrences
2024-06-12 18:18:14 +02:00
Eduard Gert
79164e9dd5 Add process posture check (#378)
* Add process posture check

* Add support for separate linux and mac paths
2024-06-12 16:32:10 +02:00
Eduard Gert
5caeab118b UX changes for modals and refactoring (#380) 2024-05-08 14:42:04 +02:00
Eduard Gert
3f943bb7d4 Use next/font/local instead of next/font/google (#376)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-04-19 17:12:56 +02:00
Eduard Gert
96b939e6cc Add changes from cloud repo to public one (#377)
* Remove unused files

* Update activity descriptions

* Update SelectDropdown

* Update redirect logic for / page

* Update HelpText.tsx

* Update wording for exit nodes
2024-04-19 17:12:37 +02:00
Eduard Gert
5e13548b81 Add better input validation for setup-keys, nameserver and routes (#373)
* Return the correct promise for errors

* Update icon

* Add better validation for routes

* Add better validation for DNS

* Add better validation for setup keys

* Merge exit nodes to input validation
2024-04-17 15:27:21 +02:00
Eduard Gert
2272a1d2a4 Add Exit Nodes (#374)
* Add exit node feature

* Fix spelling

* Hide masquerade for exit nodes

* Add exit node information to peers list

* Change exit node button, add indicator to peers table

* Add steps to route modal

* Add hook to check if peer has exit nodes

* Hide exit node indicator for regular users

* Add documentation links
2024-04-17 13:11:38 +02:00
Eduard Gert
fc3da50346 Add fallbacks for setup key name & setup key group names (#370)
* Add try catch block for global search

* Add fallback for group name

* Add fallback for setup key name

* Do not load setup key modal if it's not open

* Check if auto_groups actually exists for the setup keys

* Add fallback for group names in setup keys table

* Add fallback for group names in peers table
2024-04-11 16:42:27 +02:00
Eduard Gert
6d4716cdad Remove integrations from public repo and sync changes (#369)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Change icon size

* Remove integrations

* Add no cache header

* Add analytics event tracking

* Add small announcement improvements

* Remove peer approval setting

* Do not load countries when user has no permission

* Add tab query params to settings

* Decrease navigation font size

* Change order of providers

* Increase padding for modals

* Show page only when user is fully loaded and found

* Remove unused state

* Remove integrations page
2024-04-02 14:06:38 +02:00
amplitudes
859916b1df fix: user deletion notification (#367) 2024-04-02 12:26:45 +02:00
Eduard Gert
80ce7d21b0 Fix issue where the first users cache is not populated (#366) 2024-03-28 11:27:00 +01:00
Eduard Gert
06fdbd8ec4 Hide profile settings and announcements for blocked dashboard view (#365) 2024-03-28 10:25:21 +01:00
Eduard Gert
973cceff79 Add setting to change dashboard view for regular users (#362) 2024-03-27 16:09:58 +01:00
Eduard Gert
f4a2d6fae8 Add Okta SCIM integration (#361)
* Add Okta integration (wip)

* Update okta setup dialog

* Add okta integration images

* Add error handling for 500 status codes

* Add okta integration

* Fix lint warnings

* Update azures last sync time

* Remove 'on' from step, disable copy for HTTP Header

* Update text for custom IDP
2024-03-27 15:55:56 +01:00
Eduard Gert
cb922b46b7 Add 'Offline' filter to peers table (#364) 2024-03-26 20:03:24 +01:00
Eduard Gert
4c56ae704c Show peers for regular users but hide / disable actions (delete, enable ssh etc.) (#360)
* Show peers for regular users but hide / disable actions (delete, enable ssh etc.)

* Do not load countries for regular users
2024-03-21 14:21:26 +01:00
Eduard Gert
fe6d8c9bd5 Add support for decimal expiration time and switch to days if interval exceeds 48h (#357)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add helper function to check for integer

* Add support for decimal expiration time and switch to days if interval exceeds 48h
2024-03-15 15:54:06 +01:00
Eduard Gert
121976d101 Add option to copy peer details (ip, public ip, hostname, domain name) in detailed peer view (#356) 2024-03-15 13:46:27 +01:00
Eduard Gert
f7071e00b6 Add reset filter button (#355) 2024-03-15 13:43:00 +01:00
Eduard Gert
6b73ccf102 Fix search resetting when selecting a group (#354) 2024-03-15 13:35:25 +01:00
Eduard Gert
87dcd00264 Fix peer groups occasionally not refreshing (#351)
* Trigger groups refresh when visiting peers page

* Disable exhaustive-deps linter

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2024-03-15 13:34:47 +01:00
Eduard Gert
99f1bcc375 Reduce information visible to regular users (non-adminstrators) (#353)
reducing visibility to display only add peer information
2024-03-15 13:25:40 +01:00
Eduard Gert
bf34c55110 Fix JWT group sync checkbox using wrong variable (#352) 2024-03-12 17:23:42 +01:00
Eduard Gert
1dfc6e2d75 Add announcement banner to show updates or important information (#350)
* Add contrast color

* Add crypto-js for md5 hash

* Add announcement banner
2024-03-11 15:31:52 +01:00
Eduard Gert
b7860a8786 Filter peers by id instead of name in peer dropdown selector (#347) 2024-03-09 18:07:45 +01:00
Eduard Gert
c9172e3a5f Show full netbird logo on desktop and netbird logomark on mobile (#348) 2024-03-09 18:07:26 +01:00
Eduard Gert
78d75134f9 Add better description for posture check activity events (#349) 2024-03-09 17:14:41 +01:00
Eduard Gert
071feb02f9 Fix SSO expiration dropdown to reflect the actual "Hours" or "Days" (#345) 2024-03-01 17:01:26 +01:00
Eduard Gert
8e7bcc0c22 Extend posture checks with peer network range check (#344)
Some checks failed
build and push / build_n_push (push) Has been cancelled
add support to peer network checks
2024-02-27 16:15:47 +01:00
Eduard Gert
02a0b71e46 Fix setup key modal closing on first time creation (#342) 2024-02-26 18:02:56 +01:00
Eduard Gert
a8b66d935f Show loading indicator for peer detail view as groups are loading (#343) 2024-02-26 18:02:28 +01:00
Eduard Gert
f74f9cf812 Add region and public ip to peer table and detailed peer view (#340)
* Fix group badge icon size

* Fix copy icon size

* Add region information to peer table and single peer view

* Push to docker

* Change login expired icon size

* Fix country flag in single peer view

* Change country flag size in peer table

* Disable revalidation for countries

* Fix icon size on peer detail view

* Rollback workflow

* Revert login expiration

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2024-02-23 15:52:33 +01:00
Maycon Santos
7578595f05 Update posture checks documentation links (#339)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-02-22 21:46:55 +01:00
201 changed files with 6717 additions and 5212 deletions

View File

@@ -2,6 +2,7 @@ name: build and push
on:
push:
branches:
- "feature/**"
- main
tags:
- "**"

View File

@@ -12,4 +12,5 @@ jobs:
uses: codespell-project/actions-codespell@v2
with:
only_warn: 1
skip: package-lock.json,*.svg
skip: package-lock.json,*.svg
ignore_words_list: mappin

View File

@@ -1,4 +1,3 @@
# simple server configuration to replace nginx's default
server {
listen 80 default_server;
listen [::]:80 default_server;
@@ -7,10 +6,14 @@ server {
location / {
try_files $uri $uri.html $uri/ =404;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
expires off;
}
error_page 404 /404.html;
location = /404.html {
internal;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
expires off;
}
}

View File

@@ -5,6 +5,9 @@ const nextConfig = {
unoptimized: true,
},
reactStrictMode: false,
env: {
APP_ENV: process.env.APP_ENV || "production",
},
};
module.exports = nextConfig;

546
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "netbird-dashboard",
"version": "2.0.0",
"dependencies": {
"@axa-fr/react-oidc": "^5.14.0",
"@axa-fr/react-oidc": "^7.22.18",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -17,8 +17,9 @@
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
@@ -27,14 +28,17 @@
"@tabler/icons-react": "^2.39.0",
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/lodash": "^4.14.200",
"@types/node": "20.10.6",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-window": "^1.8.8",
"autoprefixer": "^10",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"crypto-js": "^4.2.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"eslint": "^8",
@@ -46,7 +50,7 @@
"framer-motion": "^10.16.4",
"ip-cidr": "^3.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.287.0",
"lucide-react": "^0.383.0",
"next": "13.5.5",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
@@ -60,6 +64,7 @@
"react-jwt": "^1.2.0",
"react-loading-skeleton": "^3.3.1",
"react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
@@ -91,16 +96,31 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@axa-fr/react-oidc": {
"version": "5.14.2",
"resolved": "https://registry.npmjs.org/@axa-fr/react-oidc/-/react-oidc-5.14.2.tgz",
"integrity": "sha512-N+ssJlVtVHnsvlusMxY3zLPKCB+lGzeHIxWXUb0WY3uA7Z+jxx7A2m9W1kHbhYzHuihgA3rWIcdKsvtdkeKXwg==",
"node_modules/@axa-fr/oidc-client": {
"version": "7.22.21",
"resolved": "https://registry.npmjs.org/@axa-fr/oidc-client/-/oidc-client-7.22.21.tgz",
"integrity": "sha512-w6CokGCz9Au0E3bCS5yJCUDlQemGE/TlT8jdN9FltOHI/NUw0Mn/5Rzeh/LOtlo5TIhaOS2nIlCEOY+JEIpj2w==",
"hasInstallScript": true,
"dependencies": {
"@openid/appauth": "1.3.1"
"@axa-fr/oidc-client-service-worker": "7.22.21"
}
},
"node_modules/@axa-fr/oidc-client-service-worker": {
"version": "7.22.21",
"resolved": "https://registry.npmjs.org/@axa-fr/oidc-client-service-worker/-/oidc-client-service-worker-7.22.21.tgz",
"integrity": "sha512-wDZTpRsY36sl4Ah9/ZhzDxybLj46HZjMl7Rn0qLhpK1Sb+GL+d9Agq6xNclkvizDFwuyX6hTaPGQpwcE0WNRQQ=="
},
"node_modules/@axa-fr/react-oidc": {
"version": "7.22.21",
"resolved": "https://registry.npmjs.org/@axa-fr/react-oidc/-/react-oidc-7.22.21.tgz",
"integrity": "sha512-lEdCt/q7kBXJ1AX+tEK/QAkz4p4G2qOSlhdYxPSSBRIf4ZwZEcmlH6F28W/FySk6tj/coi56dGvmcHz+hSZUDQ==",
"hasInstallScript": true,
"dependencies": {
"@axa-fr/oidc-client": "7.22.21",
"@axa-fr/oidc-client-service-worker": "7.22.21"
},
"peerDependencies": {
"react": "x",
"react-dom": "x"
"react": "^17.0.0 || ^18.0.0"
}
},
"node_modules/@babel/runtime": {
@@ -540,32 +560,6 @@
"node": ">= 8"
}
},
"node_modules/@openid/appauth": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@openid/appauth/-/appauth-1.3.1.tgz",
"integrity": "sha512-e54kpi219wES2ijPzeHe1kMnT8VKH8YeTd1GAn9BzVBmutz3tBgcG1y8a4pziNr4vNjFnuD4W446Ua7ELnNDiA==",
"dependencies": {
"@types/base64-js": "^1.3.0",
"@types/jquery": "^3.5.5",
"base64-js": "^1.5.1",
"follow-redirects": "^1.13.3",
"form-data": "^4.0.0",
"opener": "^1.5.2"
}
},
"node_modules/@openid/appauth/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -1200,26 +1194,25 @@
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz",
"integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.1.0.tgz",
"integrity": "sha512-9ArIZ9HWhsrfqS765h+GZuLoxaRHD/j0ZWOWilsCvYTpYJp8XwCqNG7Dt9Nu/TItKOdgLGkOPCodQvDc+UMwYg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/number": "1.0.1",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-direction": "1.0.1",
"@radix-ui/react-presence": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-callback-ref": "1.0.1",
"@radix-ui/react-use-layout-effect": "1.0.1"
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -1230,6 +1223,148 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
"integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz",
"integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
"integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz",
"integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz",
"integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==",
"dependencies": {
"@radix-ui/react-slot": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz",
@@ -1273,6 +1408,230 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.0.tgz",
"integrity": "sha512-dAHCDA4/ySXROEPaRtaMV5WHL8+JB/DbtyTbJjYkY0RXmKMO2Ln8DFZhywG5/mVQ4WqHDBc8smc14yPXPqZHYA==",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-collection": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-use-size": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/number": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-collection": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz",
"integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
"integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz",
"integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
"integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz",
"integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==",
"dependencies": {
"@radix-ui/react-slot": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz",
"integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-previous": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz",
"integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-size": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz",
"integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
@@ -1656,18 +2015,10 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/base64-js": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/base64-js/-/base64-js-1.3.2.tgz",
"integrity": "sha512-Q2Xn2/vQHRGLRXhQ5+BSLwhHkR3JVflxVKywH0Q6fVoAiUE8fFYL2pE5/l2ZiOiBDfA8qUqRnSxln4G/NFz1Sg=="
},
"node_modules/@types/jquery": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.29.tgz",
"integrity": "sha512-oXQQC9X9MOPRrMhPHHOsXqeQDnWeCDT3PelUIg/Oy8FAbzSZtFHRjc7IpbfFVmpLtJ+UOoywpRsuO5Jxjybyeg==",
"dependencies": {
"@types/sizzle": "*"
}
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="
},
"node_modules/@types/json5": {
"version": "0.0.29",
@@ -1710,6 +2061,14 @@
"@types/react": "*"
}
},
"node_modules/@types/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/scheduler": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
@@ -1724,7 +2083,8 @@
"node_modules/@types/sizzle": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz",
"integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg=="
"integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==",
"dev": true
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
@@ -2180,7 +2540,8 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/at-least-node": {
"version": "1.0.0",
@@ -2278,6 +2639,7 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -2920,6 +3282,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -2974,6 +3337,11 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/css-mediaquery": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz",
@@ -3158,6 +3526,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -4045,25 +4414,6 @@
"tailwindcss": "^3"
}
},
"node_modules/follow-redirects": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -5339,9 +5689,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.287.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.287.0.tgz",
"integrity": "sha512-auxP2bTGiMoELzX+6ItTeNzLmhGd/O+PHBsrXV2YwPXYCxarIFJhiMOSzFT9a1GWeYPSZtnWdLr79IVXr/5JqQ==",
"version": "0.383.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.383.0.tgz",
"integrity": "sha512-13xlG0CQCJtzjSQYwwJ3WRqMHtRj3EXmLlorrARt7y+IHnxUCp3XyFNL1DfaGySWxHObDvnu1u1dV+0VMKHUSg==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
@@ -5384,6 +5734,7 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
@@ -5392,6 +5743,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -5700,14 +6052,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
"bin": {
"opener": "bin/opener-bin.js"
}
},
"node_modules/optionator": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@@ -6334,6 +6678,18 @@
}
}
},
"node_modules/react-virtuoso": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.10.0.tgz",
"integrity": "sha512-CyxU5TYMH4bw2cybH0bNqN/yIg2q2Vd0kbs92tQc5ResZALAIzIVJY4JL6BHgJFQjwrLhCYrFwKq0p+lvBgA0w==",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16 || >=17 || >= 18",
"react-dom": ">=16 || >=17 || >= 18"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View File

@@ -13,7 +13,7 @@
"cypress:open": "cypress open"
},
"dependencies": {
"@axa-fr/react-oidc": "^5.14.0",
"@axa-fr/react-oidc": "^7.22.18",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -22,8 +22,9 @@
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
@@ -32,14 +33,17 @@
"@tabler/icons-react": "^2.39.0",
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/lodash": "^4.14.200",
"@types/node": "20.10.6",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-window": "^1.8.8",
"autoprefixer": "^10",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"crypto-js": "^4.2.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"eslint": "^8",
@@ -51,7 +55,7 @@
"framer-motion": "^10.16.4",
"ip-cidr": "^3.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.287.0",
"lucide-react": "^0.383.0",
"next": "13.5.5",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
@@ -65,6 +69,7 @@
"react-jwt": "^1.2.0",
"react-loading-skeleton": "^3.3.1",
"react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",

View File

@@ -5,14 +5,12 @@ import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useFetchApi from "@utils/api";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import { ExternalLinkIcon } from "lucide-react";
import React from "react";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import { ActivityEvent } from "@/interfaces/ActivityEvent";
import PageContainer from "@/layouts/PageContainer";
import ActivityTable from "@/modules/activity/ActivityTable";
import { EventStreamingCard } from "@/modules/integrations/event-streaming/EventStreamingCard";
export default function Activity() {
const { data: events, isLoading } = useFetchApi<ActivityEvent[]>("/events");
@@ -50,7 +48,6 @@ export default function Activity() {
</Paragraph>
</div>
<RestrictedAccess page={"Activity"}>
{(isLocalDev() || isNetBirdHosted()) && <EventStreamingCard />}
<ActivityTable events={events} isLoading={isLoading} />
</RestrictedAccess>
</PageContainer>

View File

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

View File

@@ -1,39 +0,0 @@
"use client";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import { FileText, FingerprintIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useState } from "react";
import PageContainer from "@/layouts/PageContainer";
import EventStreamingTab from "@/modules/integrations/event-streaming/EventStreamingTab";
import IdentityProviderTab from "@/modules/integrations/idp-sync/IdentityProviderTab";
export default function Integrations() {
const searchParams = useSearchParams();
const currentTab = searchParams.get("tab");
const [tab, setTab] = useState(currentTab || "event-streaming");
return (
<PageContainer>
<VerticalTabs value={tab} onChange={setTab}>
<VerticalTabs.List>
<VerticalTabs.Trigger value="event-streaming">
<FileText size={14} />
Event Streaming
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="identity-provider">
<FingerprintIcon size={14} />
Identity Provider
</VerticalTabs.Trigger>
</VerticalTabs.List>
<RestrictedAccess page={"Integrations"}>
<div className={"border-l border-nb-gray-930 w-full"}>
<EventStreamingTab />
<IdentityProviderTab />
</div>
</RestrictedAccess>
</VerticalTabs>
</PageContainer>
);
}

View File

@@ -23,33 +23,43 @@ import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import useRedirect from "@hooks/useRedirect";
import { IconCloudLock, IconInfoCircle } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import dayjs from "dayjs";
import { trim } from "lodash";
import { isEmpty, trim } from "lodash";
import {
Cpu,
FlagIcon,
Globe,
History,
LockIcon,
MapPin,
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
TerminalSquare,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { toASCII } from "punycode";
import React, { useMemo, useState } from "react";
import Skeleton from "react-loading-skeleton";
import { useSWRConfig } from "swr";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import CircleIcon from "@/assets/icons/CircleIcon";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import { useCountries } from "@/contexts/CountryProvider";
import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { useHasChanges } from "@/hooks/useHasChanges";
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
import PageContainer from "@/layouts/PageContainer";
import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton";
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import AddRouteDropdownButton from "@/modules/peer/AddRouteDropdownButton";
import PeerRoutesTable from "@/modules/peer/PeerRoutesTable";
@@ -57,8 +67,11 @@ import PeerRoutesTable from "@/modules/peer/PeerRoutesTable";
export default function PeerPage() {
const queryParameter = useSearchParams();
const peerId = queryParameter.get("id");
const { data: peer } = useFetchApi<Peer>("/peers/" + peerId);
return peer ? (
const { data: peer, isLoading } = useFetchApi<Peer>("/peers/" + peerId, true);
useRedirect("/peers", false, !peerId);
return peer && !isLoading ? (
<PeerProvider peer={peer}>
<PeerOverview />
</PeerProvider>
@@ -119,6 +132,9 @@ function PeerOverview() {
});
};
const { isUser } = useLoggedInUser();
const hasExitNodes = useHasExitNodes(peer);
return (
<PageContainer>
<RoutesProvider>
@@ -139,33 +155,35 @@ function PeerOverview() {
<CircleIcon
active={peer.connected}
size={12}
className={"mb-[3px]"}
className={"mb-[3px] shrink-0"}
/>
<TextWithTooltip text={name} maxChars={30} />
<Modal
open={showEditNameModal}
onOpenChange={setShowEditNameModal}
>
<ModalTrigger>
<div
className={
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
}
>
<PencilIcon size={16} />
</div>
</ModalTrigger>
<EditNameModal
onSuccess={(newName) => {
setName(newName);
setShowEditNameModal(false);
}}
peer={peer}
initialName={name}
key={showEditNameModal ? 1 : 0}
/>
</Modal>
{!isUser && (
<Modal
open={showEditNameModal}
onOpenChange={setShowEditNameModal}
>
<ModalTrigger>
<div
className={
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
}
>
<PencilIcon size={16} />
</div>
</ModalTrigger>
<EditNameModal
onSuccess={(newName) => {
setName(newName);
setShowEditNameModal(false);
}}
peer={peer}
initialName={name}
key={showEditNameModal ? 1 : 0}
/>
</Modal>
)}
</h1>
<LoginExpiredBadge loginExpired={peer.login_expired} />
</div>
@@ -187,7 +205,7 @@ function PeerOverview() {
variant={"primary"}
className={"w-full"}
onClick={() => updatePeer()}
disabled={!hasChanges}
disabled={!hasChanges || isUser}
>
Save Changes
</Button>
@@ -205,18 +223,32 @@ function PeerOverview() {
"flex gap-2 items-center !text-nb-gray-300 text-xs"
}
>
<IconInfoCircle size={14} />
<span>
Login expiration is disabled for all peers added with an
setup-key.
</span>
{!peer.user_id ? (
<>
<>
<IconInfoCircle size={14} />
<span>
Login expiration is disabled for all peers added
with an setup-key.
</span>
</>
</>
) : (
<>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
</>
)}
</div>
}
className={"w-full block"}
disabled={!!peer.user_id}
disabled={!!peer.user_id && !isUser}
>
<FancyToggleSwitch
disabled={!peer.user_id}
disabled={!peer.user_id || isUser}
value={loginExpiration}
onChange={setLoginExpiration}
label={
@@ -230,33 +262,75 @@ function PeerOverview() {
}
/>
</FullTooltip>
<FancyToggleSwitch
value={ssh}
onChange={(set) =>
!set
? setSsh(false)
: openSSHDialog().then((confirm) => setSsh(confirm))
<FullTooltip
content={
<div
className={
"flex gap-2 items-center !text-nb-gray-300 text-xs"
}
>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
</div>
}
label={
<>
<TerminalSquare size={16} />
SSH Access
</>
}
helpText={
"Enable the SSH server on this peer to access the machine via an secure shell."
}
/>
interactive={false}
className={"w-full block"}
disabled={!isUser}
>
<FancyToggleSwitch
value={ssh}
disabled={isUser}
onChange={(set) =>
!set
? setSsh(false)
: openSSHDialog().then((confirm) => setSsh(confirm))
}
label={
<>
<TerminalSquare size={16} />
SSH Access
</>
}
helpText={
"Enable the SSH server on this peer to access the machine via an secure shell."
}
/>
</FullTooltip>
<div>
<Label>Assigned Groups</Label>
<HelpText>
Use groups to control what this peer can access.
</HelpText>
<PeerGroupSelector
onChange={setSelectedGroups}
values={selectedGroups}
peer={peer}
/>
<FullTooltip
content={
<div
className={
"flex gap-2 items-center !text-nb-gray-300 text-xs"
}
>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
</div>
}
interactive={false}
className={"w-full block"}
disabled={!isUser}
>
<PeerGroupSelector
disabled={isUser}
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
peer={peer}
/>
</FullTooltip>
</div>
</div>
</div>
@@ -264,7 +338,7 @@ function PeerOverview() {
<Separator />
{isLinux ? (
{isLinux && !isUser ? (
<div className={"px-8 py-6"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
@@ -276,7 +350,8 @@ function PeerOverview() {
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div>
<div className={"gap-4 flex"}>
<AddExitNodeButton peer={peer} firstTime={!hasExitNodes} />
<AddRouteDropdownButton />
</div>
</div>
@@ -291,10 +366,18 @@ function PeerOverview() {
}
function PeerInformationCard({ peer }: { peer: Peer }) {
const { isLoading, getRegionByPeer } = useCountries();
const countryText = useMemo(() => {
return getRegionByPeer(peer);
}, [getRegionByPeer, peer]);
return (
<Card>
<Card.List>
<Card.ListItem
copy
copyText={"NetBird IP-Address"}
label={
<>
<MapPin size={16} />
@@ -305,6 +388,20 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
/>
<Card.ListItem
copy
copyText={"Public IP-Address"}
label={
<>
<NetworkIcon size={16} />
Public IP-Address
</>
}
value={peer.connection_ip}
/>
<Card.ListItem
copy
copyText={"Domain name"}
label={
<>
<Globe size={16} />
@@ -313,7 +410,10 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
}
value={peer.dns_label}
/>
<Card.ListItem
copy
copyText={"Hostname"}
label={
<>
<MonitorSmartphoneIcon size={16} />
@@ -322,6 +422,35 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
}
value={peer.hostname}
/>
<Card.ListItem
label={
<>
<FlagIcon size={16} />
Region
</>
}
tooltip={false}
value={
isEmpty(peer.country_code) ? (
"Unknown"
) : (
<>
{isLoading ? (
<Skeleton width={140} />
) : (
<div className={"flex gap-2 items-center"}>
<div className={"border-0 border-nb-gray-800 rounded-full"}>
<RoundedFlag country={peer.country_code} size={12} />
</div>
{countryText}
</div>
)}
</>
)
}
/>
<Card.ListItem
label={
<>
@@ -347,6 +476,7 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
")"
}
/>
<Card.ListItem
label={
<>

View File

@@ -4,19 +4,38 @@ import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import useFetchApi from "@utils/api";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import { useUsers } from "@/contexts/UsersProvider";
import { Peer } from "@/interfaces/Peer";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
import { useLoggedInUser, useUsers } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
export default function Peers() {
const { data: peers, isLoading } = useFetchApi<Peer[]>("/peers");
const { permission } = useLoggedInUser();
return (
<PageContainer>
{permission.dashboard_view === "blocked" ? (
<PeersBlockedView />
) : (
<PeersProvider>
<PeersView />
</PeersProvider>
)}
</PageContainer>
);
}
function PeersView() {
const { peers, isLoading } = usePeers();
const { users } = useUsers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const peersWithUser = peers?.map((peer) => {
if (!users) return peer;
@@ -27,7 +46,7 @@ export default function Peers() {
});
return (
<PageContainer>
<>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
@@ -36,7 +55,7 @@ export default function Peers() {
icon={<PeerIcon size={13} />}
/>
</Breadcrumbs>
<h1>{peers && peers.length > 1 ? `${peers.length} Peers` : "Peers"}</h1>
<h1 ref={headingRef}>Peers</h1>
<Paragraph>
A list of all machines and devices connected to your private network.
Use this view to manage peers.
@@ -54,8 +73,43 @@ export default function Peers() {
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable isLoading={isLoading} peers={peersWithUser} />
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
/>
</Suspense>
</PageContainer>
</>
);
}
function PeersBlockedView() {
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new device to your network</h1>
<Paragraph className={"inline"}>
To get started, install NetBird and log in using your email account.
After that you should be connected. If you have further questions
check out our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"}
>
Installation Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div
className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
}
>
<SetupModalContent header={false} footer={false} />
</div>
</div>
</div>
);
}

View File

@@ -48,7 +48,7 @@ export default function PostureChecksPage() {
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={"#"} target={"_blank"}>
<InlineLink href={"https://docs.netbird.io/how-to/manage-posture-checks"} target={"_blank"}>
Posture Checks
<ExternalLinkIcon size={12} />
</InlineLink>

View File

@@ -2,20 +2,35 @@
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import { AlertOctagonIcon, FolderGit2Icon, ShieldIcon } from "lucide-react";
import React, { useState } from "react";
import {
AlertOctagonIcon,
FolderGit2Icon,
LockIcon,
ShieldIcon,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { useAccount } from "@/modules/account/useAccount";
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
import GroupsTab from "@/modules/settings/GroupsTab";
import PermissionsTab from "@/modules/settings/PermissionsTab";
export default function NetBirdSettings() {
const [tab, setTab] = useState("authentication");
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const [tab, setTab] = useState(queryTab || "authentication");
const { isOwner } = useLoggedInUser();
const account = useAccount();
useEffect(() => {
if (queryTab) {
setTab(queryTab);
}
}, [queryTab]);
return (
<PageContainer>
<VerticalTabs value={tab} onChange={setTab}>
@@ -28,6 +43,10 @@ export default function NetBirdSettings() {
<FolderGit2Icon size={14} />
Groups
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="permissions">
<LockIcon size={14} />
Permissions
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
<AlertOctagonIcon size={14} />
Danger zone
@@ -36,6 +55,7 @@ export default function NetBirdSettings() {
<RestrictedAccess page={"Settings"}>
<div className={"border-l border-nb-gray-930 w-full"}>
{account && <AuthenticationTab account={account} />}
{account && <PermissionsTab account={account} />}
{account && <GroupsTab account={account} />}
{account && <DangerZoneTab account={account} />}
</div>

View File

@@ -7,7 +7,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import React, { lazy, Suspense, useMemo } from "react";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import { useGroups } from "@/contexts/GroupsProvider";
import { Group } from "@/interfaces/Group";
@@ -22,16 +22,21 @@ export default function SetupKeys() {
const { data: setupKeys, isLoading } = useFetchApi<SetupKey[]>("/setup-keys");
const { groups } = useGroups();
const setupKeysWithGroups = setupKeys?.map((setupKey) => {
if (!setupKey.auto_groups) return setupKey;
if (!groups) return setupKey;
return {
...setupKey,
groups: setupKey.auto_groups.map((group) => {
return groups.find((g) => g.id === group) || undefined;
}) as Group[] | undefined,
};
});
const setupKeysWithGroups = useMemo(() => {
if (!setupKeys) return [];
return setupKeys?.map((setupKey) => {
if (!setupKey.auto_groups) return setupKey;
if (!groups) return setupKey;
return {
...setupKey,
groups: setupKey.auto_groups
?.map((group) => {
return groups.find((g) => g.id === group) || undefined;
})
.filter((group) => group !== undefined) as Group[],
};
});
}, [setupKeys, groups]);
return (
<PageContainer>

View File

@@ -10,6 +10,7 @@ import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useRedirect from "@hooks/useRedirect";
import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react";
import useFetchApi, { useApiCall } from "@utils/api";
import { generateColorFromString } from "@utils/helpers";
@@ -42,6 +43,8 @@ export default function UserPage() {
return users?.find((u) => u.id === userId);
}, [users, userId]);
useRedirect("/team/users", false, !userId);
return !isLoading && user ? (
<UserOverview user={user} />
) : (
@@ -57,7 +60,7 @@ function UserOverview({ user }: Props) {
const router = useRouter();
const userRequest = useApiCall<User>("/users");
const { mutate } = useSWRConfig();
const { loggedInUser, isOwnerOrAdmin } = useLoggedInUser();
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
const initialGroups = user.auto_groups;
@@ -104,6 +107,7 @@ function UserOverview({ user }: Props) {
<Breadcrumbs.Item
href={"/team"}
label={"Team"}
disabled={isUser}
icon={<TeamIcon size={13} />}
/>
@@ -117,6 +121,7 @@ function UserOverview({ user }: Props) {
<Breadcrumbs.Item
href={"/team/users"}
label={"Users"}
disabled={isUser}
icon={<User2 size={16} />}
/>
)}
@@ -156,31 +161,33 @@ function UserOverview({ user }: Props) {
</h1>
</div>
</div>
<div className={"flex gap-4"}>
<Button
variant={"default"}
className={"w-full"}
onClick={() => {
user.is_service_user
? router.push("/team/service-users")
: router.push("/team/users");
}}
>
Cancel
</Button>
{!isUser && (
<div className={"flex gap-4"}>
<Button
variant={"default"}
className={"w-full"}
onClick={() => {
user.is_service_user
? router.push("/team/service-users")
: router.push("/team/users");
}}
>
Cancel
</Button>
<Button
variant={"primary"}
className={"w-full"}
disabled={!hasChanges}
onClick={save}
>
Save Changes
</Button>
</div>
<Button
variant={"primary"}
className={"w-full"}
disabled={!hasChanges}
onClick={save}
>
Save Changes
</Button>
</div>
)}
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl"}>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<UserInformationCard user={user} />
<div className={"flex flex-col gap-8 w-1/2 "}>
{!user.is_service_user && (
@@ -190,8 +197,10 @@ function UserOverview({ user }: Props) {
Groups will be assigned to peers added by this user.
</HelpText>
<PeerGroupSelector
disabled={isUser}
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
/>
</div>
)}
@@ -206,6 +215,8 @@ function UserOverview({ user }: Props) {
<UserRoleSelector
value={role}
onChange={setRole}
hideOwner={user.is_service_user}
currentUser={user}
disabled={
isLoggedInUser ||
!isOwnerOrAdmin ||
@@ -293,15 +304,18 @@ function UserInformationCard({ user }: { user: User }) {
{!isServiceUser && (
<>
<Card.ListItem
label={
<>
<Ban size={16} />
Block User
</>
}
value={<UserBlockCell user={user} isUserPage={true} />}
/>
{!user.is_current && user.role != Role.Owner && (
<Card.ListItem
label={
<>
<Ban size={16} />
Block User
</>
}
value={<UserBlockCell user={user} isUserPage={true} />}
/>
)}
<Card.ListItem
label={
<>

View File

@@ -64,4 +64,13 @@ p {
display: table;
position: relative;
width: 100%;
}
.stepper-bg-variant .step-circle {
@apply !border-[#1d2024];
}
.webkit-scroll{
-webkit-overflow-scrolling: touch;
-webkit-transform: translate3d(0, 0, 0);
}

View File

@@ -1,14 +1,40 @@
"use client";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRedirect } from "@hooks/useRedirect";
import { useEffect, useState } from "react";
type Props = {
url: string;
queryParams?: string;
};
export default function NotFound() {
const router = useRouter();
useEffect(() => {
router.push("/peers");
});
const [mounted, setMounted] = useState(false);
const [tempQueryParams, setTempQueryParams] = useLocalStorage(
"netbird-query-params",
"",
);
const [queryParams, setQueryParams] = useState("");
return <FullScreenLoading />;
useEffect(() => {
setQueryParams(tempQueryParams);
setTempQueryParams("");
setMounted(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return mounted ? (
<Redirect
url={window?.location?.pathname || "/"}
queryParams={queryParams}
/>
) : (
<FullScreenLoading />
);
}
const Redirect = ({ url, queryParams }: Props) => {
useRedirect("/peers" + (queryParams && `?${queryParams}`));
return <FullScreenLoading />;
};

View File

@@ -1,9 +1,41 @@
"use client";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRedirect } from "@hooks/useRedirect";
import { useEffect, useState } from "react";
type Props = {
url: string;
queryParams?: string;
};
export default function Home() {
useRedirect("/peers");
return <FullScreenLoading />;
const [mounted, setMounted] = useState(false);
const [tempQueryParams, setTempQueryParams] = useLocalStorage(
"netbird-query-params",
"",
);
const [queryParams, setQueryParams] = useState("");
useEffect(() => {
setQueryParams(tempQueryParams);
setTempQueryParams("");
setMounted(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return mounted ? (
<Redirect
url={window?.location?.pathname || "/"}
queryParams={queryParams}
/>
) : (
<FullScreenLoading />
);
}
const Redirect = ({ url, queryParams }: Props) => {
useRedirect(url == "/" ? "/peers" : url + (queryParams && `?${queryParams}`));
return <FullScreenLoading />;
};

View File

@@ -1,20 +0,0 @@
import Image from "next/image";
import * as React from "react";
import deIcon from "@/assets/countries/de.svg";
export const CountryDERounded = () => {
return (
<div
className={
"w-5 h-5 overflow-hidden rounded-full relative shadow-2xl border border-nb-gray-600 flex items-center justify-center"
}
>
<Image
src={deIcon}
alt={"de"}
fill={true}
className={"object-cover object-center"}
/>
</div>
);
};

View File

@@ -1,20 +0,0 @@
import Image from "next/image";
import * as React from "react";
import euIcon from "@/assets/countries/eu.svg";
export const CountryEURounded = () => {
return (
<div
className={
"w-5 h-5 overflow-hidden rounded-full relative shadow-2xl border border-nb-gray-600 flex items-center justify-center"
}
>
<Image
src={euIcon}
alt={"eu"}
fill={true}
className={"object-cover object-center shrink-0"}
/>
</div>
);
};

View File

@@ -1,20 +0,0 @@
import Image from "next/image";
import * as React from "react";
import jpIcon from "@/assets/countries/jp.svg";
export const CountryJPRounded = () => {
return (
<div
className={
"w-5 h-5 overflow-hidden rounded-full relative shadow-2xl border border-nb-gray-600 flex items-center justify-center"
}
>
<Image
src={jpIcon}
alt={"eu"}
fill={true}
className={"object-cover object-center"}
/>
</div>
);
};

View File

@@ -1,20 +0,0 @@
import Image from "next/image";
import * as React from "react";
import usIcon from "@/assets/countries/us.svg";
export const CountryUSRounded = () => {
return (
<div
className={
"w-5 h-5 overflow-hidden rounded-full relative shadow-2xl border border-nb-gray-600 flex items-center justify-center"
}
>
<Image
src={usIcon}
alt={"us"}
fill={true}
className={"object-cover object-center"}
/>
</div>
);
};

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600" viewBox="0 0 5 3">
<desc>Flag of Germany</desc>
<rect id="black_stripe" width="5" height="3" y="0" x="0" fill="#000"/>
<rect id="red_stripe" width="5" height="2" y="1" x="0" fill="#D00"/>
<rect id="gold_stripe" width="5" height="1" y="2" x="0" fill="#FFCE00"/>
</svg>

Before

Width:  |  Height:  |  Size: 493 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 810 540"><defs><g id="d"><g id="b"><path id="a" d="M0 0v1h.5z" transform="rotate(18 3.157 -.5)"/><use xlink:href="#a" transform="scale(-1 1)"/></g><g id="c"><use xlink:href="#b" transform="rotate(72)"/><use xlink:href="#b" transform="rotate(144)"/></g><use xlink:href="#c" transform="scale(-1 1)"/></g></defs><path fill="#039" d="M0 0h810v540H0z"/><g fill="#fc0" transform="matrix(30 0 0 30 405 270)"><use xlink:href="#d" y="-6"/><use xlink:href="#d" y="6"/><g id="e"><use xlink:href="#d" x="-6"/><use xlink:href="#d" transform="rotate(-144 -2.344 -2.11)"/><use xlink:href="#d" transform="rotate(144 -2.11 -2.344)"/><use xlink:href="#d" transform="rotate(72 -4.663 -2.076)"/><use xlink:href="#d" transform="rotate(72 -5.076 .534)"/></g><use xlink:href="#e" transform="scale(-1 1)"/></g></svg>

Before

Width:  |  Height:  |  Size: 888 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 600">
<rect fill="#fff" height="600" width="900"/>
<circle fill="#bc002d" cx="450" cy="300" r="180"/>
</svg>

Before

Width:  |  Height:  |  Size: 166 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 7410 3900"><path fill="#b22234" d="M0 0h7410v3900H0z"/><path d="M0 450h7410m0 600H0m0 600h7410m0 600H0m0 600h7410m0 600H0" stroke="#fff" stroke-width="300"/><path fill="#3c3b6e" d="M0 0h2964v2100H0z"/><g fill="#fff"><g id="d"><g id="c"><g id="e"><g id="b"><path id="a" d="M247 90l70.534 217.082-184.66-134.164h228.253L176.466 307.082z"/><use xlink:href="#a" y="420"/><use xlink:href="#a" y="840"/><use xlink:href="#a" y="1260"/></g><use xlink:href="#a" y="1680"/></g><use xlink:href="#b" x="247" y="210"/></g><use xlink:href="#c" x="494"/></g><use xlink:href="#d" x="988"/><use xlink:href="#c" x="1976"/><use xlink:href="#e" x="2470"/></g></svg>

Before

Width:  |  Height:  |  Size: 741 B

BIN
src/assets/fonts/Inter.ttf Normal file

Binary file not shown.

View File

@@ -16,6 +16,8 @@ export default function CircleIcon({
return (
<span
style={{ width: size + "px", height: size + "px" }}
data-cy="circle-icon"
data-cy-status={active ? "active" : "inactive"}
className={cn(
"rounded-full",
active

View File

@@ -5,7 +5,7 @@ export type IconProps = {
};
export const defaultIconProps: IconProps = {
size: 16,
size: 15,
className:
"dark:fill-nb-gray-400 fill-gray-500 peer-data-[active=true]/icon:dark:fill-white peer-data-[active=true]/icon:fill-gray-900 shrink-0",
autoHeight: false,

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,19 @@
<svg width="133" height="23" viewBox="0 0 133 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_0_3)">
<path d="M46.9438 7.5013C48.1229 8.64688 48.7082 10.3025 48.7082 12.4683V21.6663H46.1411V12.8362C46.1411 11.2809 45.7481 10.0851 44.9704 9.26566C44.1928 8.43783 43.1308 8.0281 41.7846 8.0281C40.4383 8.0281 39.3345 8.45455 38.5234 9.30747C37.7123 10.1604 37.3109 11.4063 37.3109 13.0369V21.6663H34.7188V6.06305H37.3109V8.28732C37.821 7.49294 38.5234 6.87416 39.4014 6.43934C40.2878 6.00452 41.2578 5.78711 42.3197 5.78711C44.2179 5.78711 45.7565 6.36408 46.9355 7.50966L46.9438 7.5013Z" fill="#F2F2F2"/>
<path d="M67.1048 14.8344H54.6288C54.7208 16.373 55.2476 17.5771 56.2092 18.4384C57.1708 19.2997 58.3331 19.7345 59.6961 19.7345C60.8166 19.7345 61.7531 19.4753 62.4973 18.9485C63.2499 18.4301 63.7767 17.7277 64.0777 16.858H66.8706C66.4525 18.3548 65.6163 19.5756 64.3621 20.5205C63.1078 21.4571 61.5525 21.9337 59.6878 21.9337C58.2077 21.9337 56.8865 21.5992 55.7159 20.9386C54.5452 20.278 53.6337 19.3331 52.9648 18.1039C52.2958 16.8831 51.9697 15.4616 51.9697 13.8477C51.9697 12.2339 52.2958 10.8207 52.9397 9.60825C53.5836 8.39578 54.495 7.45924 55.6573 6.80702C56.828 6.15479 58.1659 5.82031 59.6878 5.82031C61.2096 5.82031 62.4806 6.14643 63.6178 6.79029C64.7551 7.43416 65.6331 8.32052 66.2518 9.44938C66.8706 10.5782 67.18 11.8576 67.18 13.2791C67.18 13.7725 67.1549 14.2909 67.0964 14.8428L67.1048 14.8344ZM63.8603 10.1769C63.4255 9.4661 62.8318 8.92258 62.0793 8.55465C61.3267 8.18673 60.4989 8.00277 59.5874 8.00277C58.2746 8.00277 57.1625 8.42086 56.2427 9.25705C55.3228 10.0932 54.796 11.2472 54.6623 12.7356H64.5126C64.5126 11.7489 64.2952 10.896 63.8603 10.1852V10.1769Z" fill="#F2F2F2"/>
<path d="M73.7695 8.20355V17.4016C73.7695 18.1626 73.9284 18.6977 74.2545 19.0071C74.5806 19.3165 75.1409 19.4754 75.9352 19.4754H77.8418V21.6662H75.5088C74.0622 21.6662 72.9835 21.3317 72.2644 20.6711C71.5452 20.0105 71.1857 18.9151 71.1857 17.3933V8.19519H69.1621V6.0629H71.1857V2.13281H73.7779V6.0629H77.8501V8.19519H73.7779L73.7695 8.20355Z" fill="#F2F2F2"/>
<path d="M85.9022 6.68902C86.9307 6.10369 88.093 5.80266 89.4058 5.80266C90.8106 5.80266 92.0732 6.13714 93.1937 6.79773C94.3142 7.46668 95.2006 8.39485 95.8444 9.59896C96.4883 10.8031 96.8144 12.2079 96.8144 13.7966C96.8144 15.3854 96.4883 16.7818 95.8444 18.011C95.2006 19.2486 94.3142 20.2018 93.1854 20.8875C92.0565 21.5732 90.7939 21.916 89.4141 21.916C88.0344 21.916 86.8805 21.6234 85.8687 21.0297C84.8569 20.4443 84.0876 19.6918 83.5775 18.7803V21.6568H80.9854V0.601562H83.5775V8.97182C84.1127 8.04365 84.8904 7.28272 85.9105 6.69738L85.9022 6.68902ZM93.4529 10.7362C92.9763 9.86654 92.3408 9.19759 91.5297 8.74605C90.7186 8.29451 89.8322 8.06037 88.8706 8.06037C87.909 8.06037 87.0394 8.29451 86.2366 8.75441C85.4255 9.22268 84.7817 9.89163 84.2967 10.778C83.8117 11.6643 83.5692 12.6845 83.5692 13.8384C83.5692 14.9924 83.8117 16.046 84.2967 16.9323C84.7817 17.8187 85.4255 18.4877 86.2366 18.9559C87.0394 19.4242 87.9174 19.65 88.8706 19.65C89.8239 19.65 90.727 19.4158 91.5297 18.9559C92.3324 18.4877 92.9763 17.8187 93.4529 16.9323C93.9296 16.046 94.1637 15.0091 94.1637 13.8134C94.1637 12.6176 93.9296 11.6142 93.4529 10.7362Z" fill="#F2F2F2"/>
<path d="M100.318 3.01864C99.9749 2.67581 99.8076 2.25771 99.8076 1.76436C99.8076 1.27101 99.9749 0.852913 100.318 0.510076C100.661 0.167238 101.079 0 101.572 0C102.065 0 102.45 0.167238 102.784 0.510076C103.119 0.852913 103.286 1.27101 103.286 1.76436C103.286 2.25771 103.119 2.67581 102.784 3.01864C102.45 3.36148 102.049 3.52872 101.572 3.52872C101.095 3.52872 100.661 3.36148 100.318 3.01864ZM102.826 6.06237V21.6657H100.234V6.06237H102.826Z" fill="#F2F2F2"/>
<path d="M111.773 6.52155C112.617 6.0282 113.646 5.77734 114.867 5.77734V8.45315H114.181C111.28 8.45315 109.825 10.0252 109.825 13.1776V21.6649H107.232V6.06165H109.825V8.5953C110.276 7.70058 110.928 7.00654 111.773 6.51319V6.52155Z" fill="#F2F2F2"/>
<path d="M117.861 9.60732C118.505 8.40321 119.391 7.46668 120.52 6.80609C121.649 6.1455 122.92 5.81102 124.325 5.81102C125.537 5.81102 126.666 6.09533 127.711 6.64721C128.757 7.20746 129.551 7.94331 130.103 8.85475V0.601562H132.72V21.6735H130.103V18.7385C129.593 19.6667 128.832 20.436 127.828 21.0297C126.825 21.6317 125.646 21.9244 124.3 21.9244C122.953 21.9244 121.657 21.5816 120.528 20.8959C119.4 20.2102 118.513 19.257 117.869 18.0194C117.226 16.7818 116.899 15.377 116.899 13.805C116.899 12.233 117.226 10.8114 117.869 9.60732H117.861ZM129.392 10.7613C128.915 9.89163 128.28 9.22268 127.469 8.75441C126.658 8.28614 125.771 8.06037 124.81 8.06037C123.848 8.06037 122.962 8.28614 122.159 8.74605C121.356 9.20595 120.729 9.86654 120.253 10.7362C119.776 11.6058 119.542 12.6343 119.542 13.8134C119.542 14.9924 119.776 16.046 120.253 16.9323C120.729 17.8187 121.365 18.4877 122.159 18.9559C122.953 19.4242 123.84 19.65 124.81 19.65C125.78 19.65 126.666 19.4158 127.469 18.9559C128.272 18.4877 128.915 17.8187 129.392 16.9323C129.869 16.046 130.103 15.0175 130.103 13.8384C130.103 12.6594 129.869 11.6393 129.392 10.7613Z" fill="#F2F2F2"/>
<path d="M21.4651 0.568359C17.8193 0.902835 16.0047 3.00167 15.3191 4.06363L4.66602 22.5183H17.5182L30.1949 0.568359H21.4651Z" fill="#F68330"/>
<path d="M17.5265 22.5187L0 3.9302C0 3.9302 19.8177 -1.39633 21.7493 15.2188L17.5265 22.5187Z" fill="#F68330"/>
<path d="M14.9255 4.75055L9.54883 14.0657L17.5177 22.5196L21.7405 15.2029C21.0715 9.49174 18.287 6.37276 14.9255 4.74219" fill="#F35E32"/>
</g>
<defs>
<clipPath id="clip0_0_3">
<rect width="132.72" height="22.5186" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -2,7 +2,7 @@ import { useOidc, useOidcUser } from "@axa-fr/react-oidc";
import Button from "@components/Button";
import Paragraph from "@components/Paragraph";
import loadConfig from "@utils/config";
import { ArrowRightIcon, LogOut } from "lucide-react";
import { ArrowRightIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import * as React from "react";
import { useEffect, useState } from "react";
@@ -55,7 +55,7 @@ export const OIDCError = () => {
variant={"primary"}
size={"sm"}
className={"mt-5"}
onClick={() => login("/", { client_id: config.clientId })}
onClick={() => logout("/", { client_id: config.clientId })}
>
Continue
<ArrowRightIcon size={16} />
@@ -83,7 +83,6 @@ export const OIDCError = () => {
onClick={() => logout("/", { client_id: config.clientId })}
>
Logout
<LogOut size={16} />
</Button>
</>
)}

View File

@@ -1,11 +1,12 @@
"use client";
import { OidcProvider } from "@axa-fr/react-oidc";
import {
AuthorityConfiguration,
OidcConfiguration,
} from "@axa-fr/react-oidc/dist/vanilla/oidc";
OidcProvider,
} from "@axa-fr/react-oidc";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRedirect } from "@hooks/useRedirect";
import loadConfig, { buildExtras } from "@utils/config";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -29,7 +30,7 @@ const auth0AuthorityConfig: AuthorityConfiguration = {
revocation_endpoint: new URL("oauth/revoke", config.authority).href,
end_session_endpoint: new URL("v2/logout", config.authority).href,
userinfo_endpoint: new URL("userinfo", config.authority).href,
//issuer: new URL("", config.authority).href,
issuer: new URL("", config.authority).href,
};
const onEvent = (configurationName: any, eventName: any, data: any) => {
@@ -43,6 +44,19 @@ export default function OIDCProvider({ children }: Props) {
const [mounted, setMounted] = useState(false);
const router = useRouter();
const path = usePathname();
const params = useSearchParams()?.toString();
const [, setQueryParams] = useLocalStorage("netbird-query-params", params);
useEffect(() => {
if (
params?.includes("tab") ||
params?.includes("search") ||
params?.includes("id")
) {
setQueryParams(params);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const withCustomHistory = () => {
return {

View File

@@ -11,9 +11,17 @@ export const SecureProvider = ({ children }: Props) => {
const currentPath = usePathname();
useEffect(() => {
let timeout: NodeJS.Timeout | undefined = undefined;
if (!isAuthenticated) {
login(currentPath);
timeout = setTimeout(async () => {
if (!isAuthenticated) {
await login(currentPath);
}
}, 1500);
}
return () => {
clearTimeout(timeout);
};
}, [currentPath, isAuthenticated, login]);
return (

View File

@@ -15,13 +15,25 @@ type ItemProps = {
label: string;
icon?: React.ReactNode;
active?: boolean;
disabled?: boolean;
};
export const Item = ({ href, label, icon, active }: ItemProps) => {
export const Item = ({
href,
label,
icon,
active,
disabled = false,
}: ItemProps) => {
const router = useRouter();
return (
<div className={"flex items-center gap-2 group"}>
<div
className={cn(
"flex items-center gap-2 group",
disabled && "pointer-events-none",
)}
>
<ChevronRightIcon
size={16}
className={"text-nb-gray-400 group-first:hidden"}

View File

@@ -5,14 +5,16 @@ import React, { forwardRef } from "react";
type Props = {
children: React.ReactNode;
disabled?: boolean;
className?: string;
};
function ButtonGroup({ children, disabled }: Props) {
function ButtonGroup({ children, disabled, className }: Props) {
return (
<div
className={cn(
"rounded-lg border-[1px] dark:border-nb-gray-900 border-neutral-200 overflow-hidden flex items-center justify-center shrink-0 border-separate",
disabled ? "opacity-100 !border-nb-gray-900/20" : "",
className,
)}
>
{children}
@@ -21,7 +23,10 @@ function ButtonGroup({ children, disabled }: Props) {
}
const ButtonGroupButton = forwardRef(
({ ...props }: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
(
{ className, ...props }: ButtonProps,
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
return (
<Button
ref={ref}
@@ -31,6 +36,7 @@ const ButtonGroupButton = forwardRef(
className={cn(
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[41px]",
"!py-2.5 !px-4",
className,
)}
/>
);

View File

@@ -30,6 +30,8 @@ type CardListItemProps = {
value: React.ReactNode;
className?: string;
copy?: boolean;
copyText?: string;
tooltip?: boolean;
};
function CardListItem({
@@ -37,6 +39,8 @@ function CardListItem({
value,
className,
copy = false,
copyText,
tooltip = true,
}: CardListItemProps) {
const [, copyToClipBoard] = useCopyToClipboard(value as string);
@@ -54,11 +58,18 @@ function CardListItem({
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
)}
onClick={() =>
copy && copyToClipBoard(`${label} has been copied to clipboard.`)
copy &&
copyToClipBoard(
`${copyText ? copyText : label} has been copied to clipboard.`,
)
}
>
<TextWithTooltip text={value as string} maxChars={40} />
{copy && <Copy size={13} />}
{tooltip ? (
<TextWithTooltip text={value as string} maxChars={40} />
) : (
value
)}
{copy && <Copy size={13} className={"shrink-0"} />}
</div>
</li>
);

View File

@@ -28,12 +28,16 @@ export default function CopyToClipboardText({ children, message }: Props) {
{copied ? (
<CheckIcon
className={"text-nb-gray-100 opacity-0 group-hover:opacity-100"}
className={
"text-nb-gray-100 opacity-0 group-hover:opacity-100 shrink-0"
}
size={12}
/>
) : (
<CopyIcon
className={"text-nb-gray-100 opacity-0 group-hover:opacity-100"}
className={
"text-nb-gray-100 opacity-0 group-hover:opacity-100 shrink-0"
}
size={12}
/>
)}

View File

@@ -0,0 +1,11 @@
import * as React from "react";
type Props = {
children: React.ReactNode;
};
export const DropdownInfoText = ({ children }: Props) => {
return (
<div className={"text-center pt-2 mb-6 text-nb-gray-400"}>{children}</div>
);
};

View File

@@ -0,0 +1,48 @@
import { IconArrowBack } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { SearchIcon } from "lucide-react";
import * as React from "react";
import { forwardRef } from "react";
type Props = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
};
export const DropdownInput = forwardRef<HTMLInputElement, Props>(
({ value, onChange, placeholder = "Search..." }, ref) => {
return (
<div className={"relative w-full"}>
<input
ref={ref}
className={cn(
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
<div className={"absolute left-0 top-0 h-full flex items-center pl-4"}>
<div className={"flex items-center"}>
<SearchIcon size={14} />
</div>
</div>
<div className={"absolute right-0 top-0 h-full flex items-center pr-4"}>
<div
className={
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
}
>
<IconArrowBack size={10} />
</div>
</div>
</div>
);
},
);
DropdownInput.displayName = "DropdownInput";

View File

@@ -50,6 +50,7 @@ export default function FullTooltip({
className={cn(
isAction ? "cursor-pointer" : "cursor-default",
"inline-flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md",
className,
)}
>
{children}

View File

@@ -12,14 +12,14 @@ export default function HelpText({
className,
}: Props) {
return (
<p
<span
className={cn(
"text-[.8rem] dark:text-nb-gray-300",
"text-[.8rem] dark:text-nb-gray-300 block font-light tracking-wide",
margin && "mb-2",
className,
)}
>
{children}
</p>
</span>
);
}

View File

@@ -13,6 +13,7 @@ export interface InputProps
icon?: React.ReactNode;
error?: string;
errorTooltip?: boolean;
errorTooltipPosition?: "top" | "top-right";
}
const inputVariants = cva("", {
@@ -49,6 +50,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
maxWidthClass = "",
error,
errorTooltip = false,
errorTooltipPosition = "top",
...props
},
ref,
@@ -105,9 +107,12 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</div>
{error && errorTooltip && (
<div
className={
"absolute right-0 top-2 h-[0px] w-full flex items-center pr-3 justify-center"
}
className={cn(
errorTooltipPosition == "top" &&
"absolute right-0 top-2 h-[0px] w-full flex items-center pr-3 justify-center",
errorTooltipPosition == "top-right" &&
"absolute -right-6 top-2 h-[0px] w-full flex items-center pr-3 justify-end",
)}
>
<FullTooltip
content={
@@ -120,7 +125,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</div>
}
interactive={false}
align={"center"}
align={errorTooltipPosition == "top" ? "center" : "end"}
side={"top"}
keepOpen={true}
>

View File

@@ -1,5 +1,7 @@
import { CommandItem } from "@components/Command";
import FullTooltip from "@components/FullTooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { IconArrowBack } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
@@ -62,8 +64,13 @@ export function NetworkRouteSelector({
const isSearching = search.length > 0;
const found =
dropdownOptions.filter((item) => {
const hasDomains = item?.domains ? item.domains.length > 0 : false;
const domains =
hasDomains && item?.domains ? item?.domains.join(" ") : "";
return (
item.network_id.includes(search) || item.network.includes(search)
item.network_id.includes(search) ||
item.network?.includes(search) ||
domains.includes(search)
);
}).length > 0;
return isSearching && !found;
@@ -102,12 +109,12 @@ export function NetworkRouteSelector({
{value ? (
<div
className={
"flex items-center justify-between text-sm text-white w-full pr-4 pl-1"
"flex items-center justify-between text-sm text-white w-full pr-4 pl-1 gap-2"
}
>
<div className={"flex items-center gap-2.5 text-sm"}>
<NetworkRoutesIcon size={16} />
{value.network_id}
<TextWithTooltip text={value.network_id} maxChars={15} />
</div>
<div
@@ -117,6 +124,7 @@ export function NetworkRouteSelector({
>
{value.network}
</div>
<DomainList domains={value?.domains} />
</div>
) : (
<span>Select an existing network...</span>
@@ -208,15 +216,23 @@ export function NetworkRouteSelector({
return (
<CommandItem
key={option.network + option.network_id}
value={option.network + option.network_id}
value={
option.network +
option.network_id +
option?.domains?.join(", ")
}
onSelect={() => {
togglePeer(option);
setOpen(false);
}}
className={"gap-2"}
>
<div className={"flex items-center gap-2.5 text-sm"}>
<NetworkRoutesIcon size={14} />
{option.network_id}
<TextWithTooltip
text={option.network_id}
maxChars={15}
/>
</div>
<div
@@ -226,6 +242,7 @@ export function NetworkRouteSelector({
>
{option.network}
</div>
<DomainList domains={option?.domains} />
</CommandItem>
);
})}
@@ -238,3 +255,23 @@ export function NetworkRouteSelector({
</Popover>
);
}
function DomainList({ domains }: { domains?: string[] }) {
const firstDomain = domains ? domains[0] : "";
return (
domains &&
domains.length > 0 && (
<FullTooltip
content={<div className={"text-xs max-w-sm"}>{domains.join(", ")}</div>}
>
<div
className={
"text-xs text-nb-gray-300 block min-w-0 truncate max-w-[180px]"
}
>
{firstDomain} {domains.length > 1 && "+" + (domains.length - 1)}
</div>
</FullTooltip>
)
);
}

View File

@@ -1,3 +1,4 @@
import { IconCircleX } from "@tabler/icons-react";
import type { ErrorResponse } from "@utils/api";
import { cn } from "@utils/helpers";
import classNames from "classnames";
@@ -88,7 +89,7 @@ export default function Notification<T>({
{loading ? (
<Loader2 size={14} className={"animate-spin"} />
) : error ? (
<XIcon size={14} />
<IconCircleX size={24} />
) : (
icon || <CheckIcon size={14} />
)}

View File

@@ -29,6 +29,7 @@ interface MultiSelectProps {
max?: number;
disabled?: boolean;
popoverWidth?: "auto" | number;
hideAllGroup?: boolean;
}
export function PeerGroupSelector({
onChange,
@@ -37,6 +38,7 @@ export function PeerGroupSelector({
max,
disabled = false,
popoverWidth = "auto",
hideAllGroup = false,
}: MultiSelectProps) {
const { groups, dropdownOptions, setDropdownOptions } = useGroups();
const searchRef = React.useRef<HTMLInputElement>(null);
@@ -47,7 +49,13 @@ export function PeerGroupSelector({
useEffect(() => {
if (!groups) return;
const sortedGroups = sortBy([...groups], "name") as Group[];
setDropdownOptions(unionBy(sortedGroups, dropdownOptions, "name"));
let uniqueGroups = unionBy(sortedGroups, dropdownOptions, "name");
uniqueGroups = hideAllGroup
? uniqueGroups.filter((group) => group.name !== "All")
: uniqueGroups;
setDropdownOptions(uniqueGroups);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groups]);
@@ -66,8 +74,11 @@ export function PeerGroupSelector({
const option = dropdownOptions.find((option) => option.name == name);
const groupPeers: GroupPeer[] | undefined =
(group?.peers as GroupPeer[]) || [];
groupPeers &&
groupPeers.push({ id: peer?.id as string, name: peer?.name as string });
if (peer) {
groupPeers &&
groupPeers.push({ id: peer?.id as string, name: peer?.name as string });
}
if (!group && !option) {
setDropdownOptions((previous) => [
@@ -86,8 +97,6 @@ export function PeerGroupSelector({
}
if (max == 1) setOpen(false);
setSearch("");
};
// Remove group from the groupOptions if it does not have an id
@@ -102,17 +111,18 @@ export function PeerGroupSelector({
const isSearching = search.length > 0;
const groupDoesNotExist =
dropdownOptions.filter((item) => item.name == trim(search)).length == 0;
return isSearching && groupDoesNotExist;
const isAllGroup = search.toLowerCase() == "all";
return isSearching && groupDoesNotExist && !isAllGroup;
}, [search, dropdownOptions]);
const [open, setOpen] = useState(false);
const folderIcon = useMemo(() => {
return <FolderGit2 size={12} />;
return <FolderGit2 size={12} className={"shrink-0"} />;
}, []);
const peerIcon = useMemo(() => {
return <MonitorSmartphoneIcon size={14} />;
return <MonitorSmartphoneIcon size={14} className={"shrink-0"} />;
}, []);
const [slice, setSlice] = useState(10);
@@ -145,6 +155,7 @@ export function PeerGroupSelector({
"min-h-[46px] w-full relative items-center",
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
"disabled:pointer-events-none disabled:opacity-30",
)}
disabled={disabled}
ref={inputRef}
@@ -199,11 +210,12 @@ export function PeerGroupSelector({
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
data-cy={"group-search-input"}
className={cn(
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)}
ref={searchRef}
value={search}
@@ -238,9 +250,7 @@ export function PeerGroupSelector({
<CommandGroup>
<ScrollArea
className={
"max-h-[195px] overflow-y-auto flex flex-col gap-1 pl-2 py-2 pr-3"
}
className={"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3"}
>
{searchedGroupNotFound && (
<CommandItem

View File

@@ -1,21 +1,31 @@
import { CommandItem } from "@components/Command";
import { DropdownInfoText } from "@components/DropdownInfoText";
import { DropdownInput } from "@components/DropdownInput";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
import SmallOSIcon from "@components/ui/SmallOSIcon";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { IconArrowBack } from "@tabler/icons-react";
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
import { useSearch } from "@hooks/useSearch";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { sortBy, trim, unionBy } from "lodash";
import { ChevronsUpDown, MapPin, SearchIcon } from "lucide-react";
import { sortBy, unionBy } from "lodash";
import { ChevronsUpDown, MapPin } from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { memo, useEffect, useState } from "react";
import { FcLinux } from "react-icons/fc";
import { useElementSize } from "@/hooks/useElementSize";
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
const MapPinIcon = memo(() => <MapPin size={12} />);
MapPinIcon.displayName = "MapPinIcon";
const LinuxIcon = memo(() => (
<span className={"grayscale brightness-[100%] contrast-[40%]"}>
<FcLinux className={"text-white text-lg min-w-[20px] brightness-150"} />
</span>
));
LinuxIcon.displayName = "LinuxIcon";
interface MultiSelectProps {
value?: Peer;
onChange: React.Dispatch<React.SetStateAction<Peer | undefined>>;
@@ -23,6 +33,13 @@ interface MultiSelectProps {
disabled?: boolean;
}
const searchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
if (item.hostname.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().startsWith(lowerCaseQuery);
};
export function PeerSelector({
onChange,
value,
@@ -30,13 +47,16 @@ export function PeerSelector({
disabled = false,
}: MultiSelectProps) {
const { data: peers } = useFetchApi<Peer[]>("/peers");
const [dropdownOptions, setDropdownOptions] = useState<Peer[]>([]);
const searchRef = React.useRef<HTMLInputElement>(null);
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
const [search, setSearch] = useState("");
// Update dropdown options when peers change
const [unfilteredItems, setUnfilteredItems] = useState<Peer[]>([]);
const [filteredItems, search, setSearch] = useSearch(
unfilteredItems,
searchPredicate,
{ filter: true, debounce: 150 },
);
// Update unfiltered items when peers change
useEffect(() => {
if (!peers) return;
@@ -45,7 +65,7 @@ export function PeerSelector({
// Filter out peers that are not linux
options = options.filter((peer) => {
return getOperatingSystem(peer.os) == OperatingSystem.LINUX;
return getOperatingSystem(peer.os) === OperatingSystem.LINUX;
});
// Filter out excluded peers
@@ -56,7 +76,7 @@ export function PeerSelector({
});
}
setDropdownOptions(unionBy(options, dropdownOptions, "name"));
setUnfilteredItems(unionBy(options, unfilteredItems, "id"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [peers]);
@@ -68,38 +88,11 @@ export function PeerSelector({
onChange(peer);
setSearch("");
}
setOpen(false);
};
const peerNotFound = useMemo(() => {
const isSearching = search.length > 0;
// Search peer by ip or name
const peerFound =
dropdownOptions.filter((item) => {
return (
item.name.includes(search) ||
item.hostname.includes(search) ||
item.ip.includes(search)
);
}).length > 0;
return isSearching && !peerFound;
}, [search, dropdownOptions]);
const [open, setOpen] = useState(false);
const [slice, setSlice] = useState(10);
useEffect(() => {
if (open) {
setTimeout(() => {
setSlice(dropdownOptions.length);
}, 100);
} else {
setSlice(10);
}
}, [open, dropdownOptions]);
return (
<Popover
open={open}
@@ -115,7 +108,7 @@ export function PeerSelector({
<PopoverTrigger asChild>
<button
className={cn(
"min-h-[42px] w-full relative items-center group",
"min-h-[46px] w-full relative items-center group",
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer enabled:hover:dark:bg-nb-gray-900/50",
"disabled:opacity-40 disabled:cursor-default",
@@ -135,7 +128,7 @@ export function PeerSelector({
}
>
<div className={"flex items-center gap-2.5 text-sm"}>
<SmallOSIcon os={value.os} />
<LinuxIcon />
<TextWithTooltip text={value.name} maxChars={20} />
</div>
@@ -144,7 +137,7 @@ export function PeerSelector({
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]"
}
>
<MapPin size={12} />
<MapPinIcon />
{value.ip}
</div>
</div>
@@ -162,113 +155,67 @@ export function PeerSelector({
style={{
width: width,
}}
forceMount={true}
align="start"
side={"top"}
sideOffset={10}
>
<Command
className={"w-full flex"}
loop
filter={(value, search) => {
const formatValue = trim(value.toLowerCase());
const formatSearch = trim(search.toLowerCase());
if (formatValue.includes(formatSearch)) return 1;
return 0;
}}
>
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
className={cn(
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
)}
ref={searchRef}
value={search}
onValueChange={setSearch}
placeholder={"Search for peers by name or ip..."}
/>
<div
className={
"absolute left-0 top-0 h-full flex items-center pl-4"
}
>
<div className={"flex items-center"}>
<SearchIcon size={14} />
</div>
</div>
<div
className={
"absolute right-0 top-0 h-full flex items-center pr-4"
}
>
<div
className={
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
}
>
<IconArrowBack size={10} />
</div>
</div>
</div>
<div className={"w-full"}>
<DropdownInput
value={search}
onChange={setSearch}
placeholder={"Search for peers by name or ip..."}
/>
<div className={""}>
{dropdownOptions.length == 0 && !peerNotFound && (
<div
className={
"text-center pb-2 text-nb-gray-500 max-w-xs mx-auto"
}
>
{
"Seems like you don't have any linux peers to assign as a routing peer."
}
</div>
)}
{peerNotFound && (
<div className={"text-center pb-2 text-nb-gray-500"}>
There are no peers matching your search.
</div>
)}
<CommandGroup>
<ScrollArea
className={
"max-h-[180px] overflow-y-auto flex flex-col gap-1 pl-2 py-2 pr-3"
}
>
{dropdownOptions.slice(0, slice).map((option) => {
return (
<CommandItem
key={option.name}
value={option.name + option.id}
onSelect={() => {
togglePeer(option);
setOpen(false);
}}
>
<div className={"flex items-center gap-2.5 text-sm"}>
<SmallOSIcon os={option.os} />
<TextWithTooltip text={option.name} maxChars={20} />
</div>
{unfilteredItems.length == 0 && (
<DropdownInfoText>
{
"Seems like you don't have any linux peers to assign as a routing peer."
}
</DropdownInfoText>
)}
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]"
}
>
<MapPin size={12} />
{option.ip}
</div>
</CommandItem>
);
})}
</ScrollArea>
</CommandGroup>
</div>
</CommandList>
</Command>
{filteredItems.length == 0 && (
<DropdownInfoText>
There are no peers matching your search.
</DropdownInfoText>
)}
{filteredItems.length > 0 && (
<VirtualScrollAreaList
items={filteredItems}
onSelect={togglePeer}
renderItem={(option) => {
return (
<>
<div
className={cn(
"flex items-center gap-2.5 text-sm",
value && value.id == option.id
? "text-white"
: "text-nb-gray-300",
)}
>
<LinuxIcon />
<TextWithTooltip text={option.name} maxChars={20} />
</div>
<div
className={cn(
"font-medium flex items-center gap-1 font-mono text-[10px]",
value && value.id == option.id
? "text-white"
: "text-nb-gray-300",
)}
>
<MapPinIcon />
{option.ip}
</div>
</>
);
}}
/>
)}
</div>
</PopoverContent>
</Popover>
);

View File

@@ -4,30 +4,65 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@utils/helpers";
import * as React from "react";
type AdditionalScrollAreaProps = {
withoutViewport?: boolean;
};
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> &
AdditionalScrollAreaProps
>(({ className, children, withoutViewport = false, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
className={cn(
"relative overflow-hidden will-change-scroll webkit-scroll",
className,
)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
{withoutViewport ? (
children
) : (
<ScrollAreaViewport disableOverflowY={false}>
{children}
</ScrollAreaViewport>
)}
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
type AdditionalScrollAreaViewportProps = {
disableOverflowY?: boolean;
};
const ScrollAreaViewport = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport> &
AdditionalScrollAreaViewportProps
>(({ disableOverflowY = true, ...props }, ref) => {
return (
<ScrollAreaPrimitive.Viewport
ref={ref}
className="h-full w-full rounded-[inherit] will-change-scroll webkit-scroll"
{...props}
style={
disableOverflowY ? { overflowY: undefined, ...props.style } : undefined
}
/>
);
});
ScrollAreaViewport.displayName = ScrollAreaPrimitive.Viewport.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
style={{ boxSizing: "unset", overflow: undefined }}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
@@ -49,4 +84,15 @@ const ScrollBar = React.forwardRef<
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
const MemoizedScrollArea = React.memo(ScrollArea);
const MemoizedScrollAreaViewport = React.memo(ScrollAreaViewport);
const MemoizedScrollBar = React.memo(ScrollBar);
export {
MemoizedScrollArea,
MemoizedScrollAreaViewport,
MemoizedScrollBar,
ScrollArea,
ScrollAreaViewport,
ScrollBar,
};

View File

@@ -21,12 +21,19 @@ function SegmentedTabs({ value, onChange, children }: Props) {
);
}
function List({ children }: { children: React.ReactNode }) {
function List({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<TabsList
className={
"bg-nb-gray-930/70 p-1.5 rounded-t-lg flex justify-center gap-1 border border-b-0 border-nb-gray-900"
}
className={cn(
"bg-nb-gray-930/70 p-1.5 rounded-t-lg flex justify-center gap-1 border border-b-0 border-nb-gray-900",
className,
)}
>
{children}
</TabsList>

View File

@@ -60,7 +60,7 @@ export default function SidebarItem({
<li className={"px-4 cursor-pointer"}>
<button
className={classNames(
"rounded-lg text-base w-full ",
"rounded-lg text-[.95rem] w-full ",
"font-normal ",
className,
isChild ? "pl-7 pr-2 py-2 mt-1 mb-0.5" : "py-2 px-3",

27
src/components/Slider.tsx Normal file
View File

@@ -0,0 +1,27 @@
"use client";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@utils/helpers";
import React from "react";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
<SliderPrimitive.Range className="absolute h-full bg-neutral-900 dark:bg-neutral-50" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-neutral-900 bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:border-neutral-50 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -15,6 +15,7 @@ const iconVariant = cva(
green: "bg-green-950 border-green-500 text-green-500",
purple: "bg-purple-950 border-purple-500 text-purple-500",
indigo: "bg-indigo-950 border-indigo-500 text-indigo-500",
yellow: "bg-yellow-950 border-yellow-400 text-yellow-400",
},
size: {
small: "w-8 h-8",

View File

@@ -36,7 +36,7 @@ const Step = ({ children, step, line = true, center = false }: StepProps) => {
className={cn(
"h-[34px] w-[34px] shrink-0 rounded-full flex items-center justify-center font-medium text-xs relative z-0 border-4 transition-all",
"dark:bg-nb-gray-900 dark:text-nb-gray-400 dark:border-nb-gray dark:group-hover:bg-nb-gray-800",
"bg-nb-gray-100 text-nb-gray-400 border-white group-hover:bg-nb-gray-200",
"bg-nb-gray-100 text-nb-gray-400 border-white group-hover:bg-nb-gray-200 step-circle",
)}
>
{step}

View File

@@ -0,0 +1,132 @@
import {
MemoizedScrollArea,
MemoizedScrollAreaViewport,
} from "@components/ScrollArea";
import { cn } from "@utils/helpers";
import * as React from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
type Props<T extends { id?: string }> = {
items: T[];
onSelect: (item: T) => void;
renderItem?: (item: T) => React.ReactNode;
};
export function VirtualScrollAreaList<T extends { id?: string }>({
items,
onSelect,
renderItem,
}: Props<T>) {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [selected, setSelected] = useState(0);
useEffect(() => {
setSelected(0);
}, [items]);
const scrollToItem = useCallback((index: number) => {
virtuosoRef.current?.scrollIntoView({
index,
behavior: "auto",
align: "center",
});
}, []);
const navigation = useCallback(
(e: KeyboardEvent) => {
if (items.length === 0) return;
const length = items.length - 1;
if (e.code === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) {
e.preventDefault();
const newSelected = selected === 0 ? length : selected - 1;
setSelected(newSelected);
scrollToItem(newSelected);
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
const newSelected = selected === length ? 0 : selected + 1;
setSelected(newSelected);
scrollToItem(newSelected);
}
if (e.key === "Enter") {
e.preventDefault();
onSelect?.(items[selected]);
}
},
[items, scrollToItem, selected],
);
useEffect(() => {
window.addEventListener("keydown", navigation);
return () => {
window.removeEventListener("keydown", navigation);
};
}, [navigation]);
const renderMemoizedItem = useMemo(() => renderItem, [renderItem]);
return (
<MemoizedScrollArea
withoutViewport={true}
className={"max-h-[195px] flex flex-col gap-1 py-2"}
>
<Virtuoso
ref={virtuosoRef}
overscan={50}
data={items}
computeItemKey={(index) => items[index].id as string}
context={{ selected, setSelected, onClick: onSelect }}
itemContent={(index, option, { selected, setSelected, onClick }) => {
return (
<VirtualScrollListItemWrapper
onMouseEnter={() => setSelected(index)}
id={option.id}
onClick={() => onClick(option as T)}
ariaSelected={selected === index}
>
{renderMemoizedItem ? renderMemoizedItem(option) : option.id}
</VirtualScrollListItemWrapper>
);
}}
style={{ height: 195 }}
components={{
Scroller: MemoizedScrollAreaViewport,
}}
/>
</MemoizedScrollArea>
);
}
type ItemWrapperProps = {
children: React.ReactNode;
id?: string;
onMouseEnter?: () => void;
onClick?: () => void;
ariaSelected?: boolean;
};
export const VirtualScrollListItemWrapper = memo(
({ id, children, onClick, onMouseEnter, ariaSelected }: ItemWrapperProps) => {
return (
<div
key={id ?? undefined}
className={"pr-3 pl-2 webkit-scroll"}
onMouseEnter={onMouseEnter}
onClick={onClick}
>
<div
className={cn(
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md",
"bg-transparent dark:aria-selected:bg-nb-gray-800/50",
)}
aria-selected={ariaSelected}
role={"listitem"}
tabIndex={0}
>
{children}
</div>
</div>
);
},
);
VirtualScrollListItemWrapper.displayName = "VirtualScrollListItemWrapper";

View File

@@ -75,7 +75,10 @@ const ModalContent = React.forwardRef<
<>
{children}
{showClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400">
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@@ -9,6 +9,8 @@ interface Props extends IconVariant {
description: string | React.ReactNode;
className?: string;
margin?: string;
truncate?: boolean;
children?: React.ReactNode;
}
export default function ModalHeader({
icon,
@@ -17,14 +19,24 @@ export default function ModalHeader({
color = "netbird",
className = "pb-6 px-8",
margin = "mt-0",
truncate = false,
children,
}: Props) {
return (
<div className={className}>
<div className={"flex items-start gap-5 pr-10"}>
<div className={cn(className, "min-w-0")}>
<div className={"flex items-start gap-5 pr-10 min-w-0"}>
{icon && <SquareIcon color={color} icon={icon} />}
<div>
<div className={"min-w-0"}>
<h2 className={"text-lg my-0 leading-[1.5]"}>{title}</h2>
<Paragraph className={cn("text-sm", margin)}>{description}</Paragraph>
{children ? (
<>{children}</>
) : (
<Paragraph
className={cn("text-sm", margin, truncate && "!block truncate")}
>
{description}
</Paragraph>
)}
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import Button from "@components/Button";
import { CommandItem } from "@components/Command";
import Paragraph from "@components/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
import { SelectDropdownSearchInput } from "@components/select/SelectDropdownSearchInput";
@@ -31,6 +32,7 @@ interface SelectDropdownProps {
popoverWidth?: "auto" | number;
options: SelectOption[];
showSearch?: boolean;
showValues?: boolean;
placeholder?: string;
searchPlaceholder?: string;
isLoading?: boolean;
@@ -43,6 +45,7 @@ export function SelectDropdown({
popoverWidth = "auto",
options,
showSearch = false,
showValues = false,
placeholder = "Select...",
searchPlaceholder = "Search...",
isLoading = false,
@@ -186,6 +189,7 @@ export function SelectDropdown({
option={option}
toggle={toggle}
key={option.value}
showValue={showValues}
/>
))}
</div>
@@ -201,9 +205,11 @@ export function SelectDropdown({
const SelectDropdownItem = ({
option,
toggle,
showValue = false,
}: {
option: SelectOption;
toggle: (value: string) => void;
showValue?: boolean;
}) => {
const value = option.value || "" + option.label || "";
const elementRef = useRef<HTMLDivElement>(null);
@@ -233,6 +239,13 @@ const SelectDropdownItem = ({
<span className={"text-nb-gray-200"}>{option.label}</span>
</div>
</div>
{showValue && (
<div className={"flex items-center gap-2.5 p-1"}>
<Paragraph className={cn("text-sm text-right")}>
{option.value}
</Paragraph>
</div>
)}
</CommandItem>
) : (
<div className={"h-[35px] py-1 px-2"}></div>

View File

@@ -0,0 +1,36 @@
import Skeleton from "react-loading-skeleton";
export default function SkeletonPeerDetail() {
return (
<div className={"w-full mt-6 p-default"}>
<div className={"flex flex-wrap w-full justify-between max-w-6xl "}>
<Skeleton height={24} width={300} className={"rounded-md"} />
</div>
<div className={"flex flex-wrap w-full justify-between mt-4 max-w-6xl "}>
<Skeleton height={42} width={400} className={"rounded-md"} />
<div className={"flex gap-3"}>
<Skeleton height={42} width={80} className={"rounded-md"} />
<Skeleton height={42} width={120} className={"rounded-md"} />
</div>
</div>
<div
className={
"flex flex-wrap w-full justify-between mt-6 max-w-6xl gap-10"
}
>
<Skeleton
height={400}
width={"100%"}
className={"rounded-md"}
containerClassName={"flex-1 "}
/>
<Skeleton
height={300}
width={"100%"}
className={"rounded-md opacity-30"}
containerClassName={"flex-1 "}
/>
</div>
</div>
);
}

View File

@@ -1,7 +1,9 @@
"use client";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import DataTableGlobalSearch from "@components/table/DataTableGlobalSearch";
import { DataTableHeadingPortal } from "@components/table/DataTableHeadingPortal";
import { DataTablePagination } from "@components/table/DataTablePagination";
import DataTableResetFilterButton from "@components/table/DataTableResetFilterButton";
import {
Table,
TableBody,
@@ -27,6 +29,7 @@ import {
getSortedRowModel,
PaginationState,
Row,
RowSelectionState,
SortingState,
Table as TanStackTable,
useReactTable,
@@ -54,11 +57,15 @@ declare module "@tanstack/table-core" {
}
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
const val = row.getValue(columnId);
if (!val) return false;
if (typeof val !== "string") return false;
const lowerCaseValue = removeAllSpaces(trim(value.toLowerCase()));
return val.toLowerCase().includes(lowerCaseValue);
try {
const val = row.getValue(columnId);
if (!val) return false;
if (typeof val !== "string") return false;
const lowerCaseValue = removeAllSpaces(trim(value.toLowerCase()));
return val.toLowerCase().includes(lowerCaseValue);
} catch (e) {
return false;
}
};
const exactMatch: FilterFn<any> = (row, columnId, value) => {
@@ -100,6 +107,7 @@ interface DataTableProps<TData, TValue> {
aboveTable?: (table: TanStackTable<TData>) => React.ReactNode;
searchPlaceholder?: string;
columnVisibility?: VisibilityState;
setColumnVisibility?: React.Dispatch<React.SetStateAction<VisibilityState>>;
sorting?: SortingState;
setSorting?: React.Dispatch<React.SetStateAction<SortingState>>;
text?: string;
@@ -121,6 +129,11 @@ interface DataTableProps<TData, TValue> {
rightSide?: (table: TanStackTable<TData>) => React.ReactNode;
manualPagination?: boolean;
showHeader?: boolean;
rowSelection?: RowSelectionState;
setRowSelection?: React.Dispatch<React.SetStateAction<RowSelectionState>>;
useRowId?: boolean;
headingTarget?: HTMLHeadingElement | null;
showResetFilterButton?: boolean;
}
export function DataTable<TData, TValue>(props: DataTableProps<TData, TValue>) {
@@ -134,6 +147,7 @@ export function DataTableContent<TData, TValue>({
children,
searchPlaceholder = "Search...",
columnVisibility = {},
setColumnVisibility,
sorting = [],
setSorting,
text = "rows",
@@ -154,6 +168,11 @@ export function DataTableContent<TData, TValue>({
rightSide,
manualPagination = false,
showHeader = true,
rowSelection,
setRowSelection,
useRowId,
headingTarget,
showResetFilterButton = true,
}: DataTableProps<TData, TValue>) {
const path = usePathname();
const [columnFilters, setColumnFilters] = useLocalStorage<ColumnFiltersState>(
@@ -171,9 +190,6 @@ export function DataTableContent<TData, TValue>({
pageSize: 10,
});
const [tableColumnVisibility, setColumnVisibility] =
React.useState<VisibilityState>(columnVisibility);
const hasInitialData = !!(data && data.length > 0);
const table = useReactTable({
@@ -191,8 +207,9 @@ export function DataTableContent<TData, TValue>({
manualPagination: manualPagination,
state: {
sorting,
rowSelection: rowSelection ?? {},
columnFilters,
columnVisibility: tableColumnVisibility,
columnVisibility: columnVisibility,
globalFilter: globalSearch,
pagination: paginationState,
},
@@ -202,6 +219,8 @@ export function DataTableContent<TData, TValue>({
pageSize: 10,
},
},
getRowId: useRowId ? (row) => row.id : undefined,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onPaginationChange: setPaginationState,
onColumnFiltersChange: setColumnFilters,
@@ -223,6 +242,16 @@ export function DataTableContent<TData, TValue>({
const TableDataUnstyledComponent = as === "table" ? "td" : "div";
const TableRowUnstyledComponent = as === "table" ? "tr" : "div";
/**
* Reset all filters, search & set pagination to first page
*/
const resetFilters = () => {
table.setPageIndex(0);
setColumnFilters([]);
setGlobalSearch("");
setRowSelection?.({});
};
return (
<div className={cn("relative table-fixed-scroll", className)}>
{!minimal && (
@@ -234,10 +263,14 @@ export function DataTableContent<TData, TValue>({
setGlobalSearch={(val) => {
table.setPageIndex(0);
setGlobalSearch(val);
setRowSelection?.({});
}}
placeholder={searchPlaceholder}
/>
{children && children(table)}
{showResetFilterButton && (
<DataTableResetFilterButton onClick={resetFilters} table={table} />
)}
<div className={"flex gap-4 flex-wrap grow"}>
<div className={"flex gap-4 flex-wrap"}></div>
{rightSide && rightSide(table)}
@@ -397,6 +430,11 @@ export function DataTableContent<TData, TValue>({
<div className={paginationClassName}>
<DataTablePagination table={table} text={text} />
</div>
<DataTableHeadingPortal
table={table}
headingTarget={headingTarget}
text={text}
/>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { Table } from "@tanstack/react-table";
import * as React from "react";
import { useRef } from "react";
import { createPortal } from "react-dom";
type Props<TData> = {
table: Table<TData> | null;
headingTarget?: HTMLHeadingElement | null;
text: string;
};
export const DataTableHeadingPortal = function <TData>({
table,
headingTarget,
text = "Items",
}: Props<TData>) {
const hasMounted = useRef(false);
if (!headingTarget) return;
if (!hasMounted.current) {
headingTarget.innerHTML = "";
hasMounted.current = true;
}
const totalItems = table?.getPreFilteredRowModel().rows.length;
const filteredItems = table?.getFilteredRowModel().rows.length;
const hasAnyFiltersActive =
table &&
!(
table?.getState().columnFilters.length <= 0 &&
table?.getState().globalFilter === ""
);
return createPortal(
<Heading
text={text}
hasAnyFilterActive={hasAnyFiltersActive}
totalItems={totalItems}
filteredItems={filteredItems}
/>,
headingTarget,
);
};
type HeadingProps = {
hasAnyFilterActive: boolean | null;
filteredItems?: number;
totalItems?: number;
text: string;
};
const Heading = ({
hasAnyFilterActive,
filteredItems,
totalItems,
text,
}: HeadingProps) => {
if (!totalItems || totalItems == 1) {
return text;
}
if (hasAnyFilterActive) {
return (
<>
<span className={"text-netbird"}>{filteredItems}</span> of {totalItems}{" "}
{text}
</>
);
}
return `${totalItems} ${text}`;
};

View File

@@ -0,0 +1,55 @@
import Button from "@components/Button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/Tooltip";
import { Table } from "@tanstack/react-table";
import { FilterX } from "lucide-react";
import * as React from "react";
import { useState } from "react";
interface Props<TData> {
table: Table<TData>;
onClick: () => void;
}
export default function DataTableResetFilterButton<TData>({
table,
onClick,
}: Props<TData>) {
const [hovered, setHovered] = useState(false);
const isDisabled =
table.getState().columnFilters.length <= 0 &&
table.getState().globalFilter === "";
return !isDisabled ? (
<Tooltip delayDuration={1}>
<TooltipTrigger
asChild={true}
onMouseOver={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={(e) => {
e.preventDefault();
}}
>
<Button
className={"h-[42px]"}
variant={"secondary"}
disabled={isDisabled}
onClick={onClick}
>
<FilterX size={16} />
</Button>
</TooltipTrigger>
<TooltipContent
sideOffset={10}
className={"px-3 py-2"}
onPointerDownOutside={(event) => {
if (hovered) event.preventDefault();
}}
>
<span className={"text-xs text-neutral-300"}>
Reset Filters & Search
</span>
</TooltipContent>
</Tooltip>
) : null;
}

View File

@@ -28,9 +28,10 @@ export function DataTableRowsPerPage<TData>({
role="combobox"
aria-expanded={open}
disabled={disabled}
data-cy={"rows-per-page"}
className="w-[200px] justify-between"
>
<RowsIcon size={15} className={"text-nb-gray-300"} />
<RowsIcon size={15} className={"text-nb-gray-300 shrink-0"} />
<div>
<span className={"text-white"}>
{table.getState().pagination.pageSize}
@@ -47,6 +48,7 @@ export function DataTableRowsPerPage<TData>({
<CommandItem
key={val}
value={val.toString()}
data-cy={`rows-per-page-value`}
onSelect={(currentValue) => {
table.setPageSize(Number(currentValue));
setOpen(false);

View File

@@ -0,0 +1,92 @@
import InlineLink from "@components/InlineLink";
import { cn } from "@utils/helpers";
import { cva, VariantProps } from "class-variance-authority";
import { ArrowRightIcon, XIcon } from "lucide-react";
import * as React from "react";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
const variants = cva(
{},
{
variants: {
variant: {
default:
"bg-nb-gray-900/50 border-nb-gray-800/30 border-b text-nb-gray-200",
important: "from-netbird to-netbird-400 bg-gradient-to-b text-white",
},
tagBadge: {
default: "bg-nb-gray-200/10 text-nb-gray-100 font-medium",
important: "bg-white text-netbird font-medium",
},
closeButton: {
default:
"bg-nb-gray-900 rounded-md p-1 text-nb-gray-300 hover:bg-nb-gray-800",
important:
"bg-netbird-100 rounded-md p-1 text-netbird-600 hover:bg-white",
},
inlineLink: {
default: "text-nb-blue-400 hover:underline",
important: "!text-white underline hover:opacity-80",
},
},
},
);
export type AnnouncementVariant = VariantProps<typeof variants>;
export const AnnouncementBanner = () => {
const { bannerHeight, closeAnnouncement, announcements } = useAnnouncement();
const announcement = announcements?.find((a) => a.isOpen);
return announcement ? (
<div
className={cn(
"flex items-center justify-center text-sm px-8 font-light",
variants({ variant: announcement.variant }),
)}
style={{ height: bannerHeight }}
>
<div className={"flex items-center gap-2"}>
{announcement.tag && (
<div
className={cn(
"bg-nb-gray-200/10 backdrop-blur text-nb-gray-100 font-medium tracking-wide uppercase text-[10px] py-2.5 px-2 rounded-md leading-[0]",
variants({ tagBadge: announcement.variant }),
)}
>
{announcement.tag}
</div>
)}
<div>
{announcement.text}
{announcement.link && (
<InlineLink
href={announcement.link || "#"}
target={announcement.isExternal ? "_blank" : undefined}
className={cn(
"ml-2 !text-sm",
variants({ inlineLink: announcement.variant }),
)}
>
{announcement.linkText || "Learn more"}
<ArrowRightIcon size={14} />
</InlineLink>
)}
</div>
</div>
{announcement.closeable && (
<div className={"absolute right-0 px-4"}>
<div
className={cn(
"rounded-md p-1 text-nb-gray-300 transition-all cursor-pointer",
variants({ closeButton: announcement.variant }),
)}
onClick={() => closeAnnouncement(announcement.hash)}
>
<XIcon size={14} />
</div>
</div>
)}
</div>
) : null;
};

View File

@@ -2,19 +2,16 @@ import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import useFetchApi from "@utils/api";
import { createElement, useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { Country } from "@/interfaces/Country";
import { useCountries } from "@/contexts/CountryProvider";
type Props = {
value: string;
onChange: (value: string) => void;
};
export const CountrySelector = ({ value, onChange }: Props) => {
const { data: countries, isLoading } = useFetchApi<Country[]>(
"/locations/countries",
);
const { countries, isLoading } = useCountries();
const countryList = useMemo(() => {
return countries?.map((country) => {

View File

@@ -0,0 +1,70 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { GlobeIcon } from "lucide-react";
import * as React from "react";
type Props = {
domains: string[];
};
export const DomainListBadge = ({ domains }: Props) => {
const firstDomain = domains.length > 0 ? domains[0] : undefined;
return (
<DomainsTooltip domains={domains}>
<div className={"inline-flex items-center gap-2"}>
{firstDomain && (
<Badge variant={"gray"}>
<GlobeIcon size={10} />
{firstDomain}
</Badge>
)}
{domains && domains.length > 1 && (
<Badge variant={"gray"}>+ {domains.length - 1}</Badge>
)}
</div>
</DomainsTooltip>
);
};
export const DomainsTooltip = ({
domains,
children,
className,
}: {
domains: string[];
children: React.ReactNode;
className?: string;
}) => {
return (
<FullTooltip
interactive={false}
className={className}
content={
<div className={"flex flex-col gap-2 items-start"}>
{domains.map((domain) => {
return (
domain && (
<div
key={domain}
className={"flex gap-2 items-center justify-between w-full"}
>
<div
className={
"flex gap-2 items-center text-nb-gray-300 text-xs"
}
>
<GlobeIcon size={11} />
{domain}
</div>
</div>
)
);
})}
</div>
}
disabled={domains.length <= 1}
>
{children}
</FullTooltip>
);
};

View File

@@ -21,17 +21,23 @@ export default function GroupBadge({
}: Props) {
return (
<Badge
key={group.name}
key={group.id}
useHover={true}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap", className)}
onClick={onClick}
onClick={(e) => {
e.preventDefault();
onClick?.();
}}
>
<FolderGit2 size={12} />
<TextWithTooltip text={group.name} maxChars={20} />
<FolderGit2 size={12} className={"shrink-0"} />
<TextWithTooltip text={group?.name || ""} maxChars={20} />
{children}
{showX && (
<XIcon size={12} className={"cursor-pointer group-hover:text-white"} />
<XIcon
size={12}
className={"cursor-pointer group-hover:text-white shrink-0"}
/>
)}
</Badge>
);

View File

@@ -0,0 +1,88 @@
import Button from "@components/Button";
import { Input } from "@components/Input";
import { validator } from "@utils/helpers";
import { uniqueId } from "lodash";
import { GlobeIcon, MinusCircleIcon } from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { Domain } from "@/interfaces/Domain";
type Props = {
value: Domain;
onChange: (d: Domain) => void;
onRemove: () => void;
onError?: (error: boolean) => void;
error?: string;
};
enum ActionType {
ADD = "ADD",
REMOVE = "REMOVE",
UPDATE = "UPDATE",
}
export const domainReducer = (state: Domain[], action: any): Domain[] => {
switch (action.type) {
case ActionType.ADD:
return [...state, { name: "", id: uniqueId("domain") }];
case ActionType.REMOVE:
return state.filter((_, i) => i !== action.index);
case ActionType.UPDATE:
return state.map((n, i) => (i === action.index ? action.d : n));
default:
return state;
}
};
export default function InputDomain({
value,
onChange,
onRemove,
onError,
}: Readonly<Props>) {
const [name, setName] = useState(value?.name || "");
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
onChange({ ...value, name: e.target.value });
};
const domainError = useMemo(() => {
if (name == "") {
return "";
}
const valid = validator.isValidDomain(name);
if (!valid) {
return "Please enter a valid domain, e.g. example.com or intra.example.com";
}
}, [name]);
useEffect(() => {
const hasError = domainError !== "" && domainError !== undefined;
onError?.(hasError);
return () => onError?.(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [domainError]);
return (
<div className={"flex gap-2 w-full"}>
<div className={"w-full"}>
<Input
customPrefix={<GlobeIcon size={15} />}
placeholder={"e.g., example.com"}
maxWidthClass={"w-full"}
value={name}
error={domainError}
onChange={handleNameChange}
/>
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={onRemove}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
}

View File

@@ -10,7 +10,7 @@ export default function LoginExpiredBadge({ loginExpired }: Props) {
<Tooltip delayDuration={1}>
<TooltipTrigger>
<Badge variant={"red"} className={"px-3"}>
<AlertTriangle size={14} className={"mr-1"} />
<AlertTriangle size={13} className={"mr-1"} />
Login required
</Badge>
</TooltipTrigger>

View File

@@ -5,7 +5,9 @@ import * as React from "react";
type Props = {
data: {
label: string;
value: string;
value: string | React.ReactNode;
noCopy?: boolean;
tooltip?: boolean;
}[];
className?: string;
};
@@ -16,10 +18,11 @@ export const MinimalList = ({ data, className }: Props) => {
{data.map((item, index) => {
return (
<Card.ListItem
copy
copy={!item.noCopy}
label={item.label}
value={item.value}
key={index}
tooltip={item.tooltip !== false}
/>
);
})}

View File

@@ -1,26 +0,0 @@
import Image from "next/image";
import { useMemo } from "react";
import { FaWindows } from "react-icons/fa6";
import { FcAndroidOs, FcLinux } from "react-icons/fc";
import AppleLogo from "@/assets/os-icons/apple.svg";
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
export default function SmallOSIcon({ os }: { os: string }) {
const icon = useMemo(() => {
return getOperatingSystem(os.toLowerCase());
}, [os]);
if (icon === OperatingSystem.WINDOWS)
return <FaWindows className={"text-white text-md min-w-[20px]"} />;
if (icon === OperatingSystem.APPLE)
return (
<div className={"min-w-[20px] flex items-center justify-center"}>
<Image src={AppleLogo} alt={""} width={12} />
</div>
);
if (icon === OperatingSystem.ANDROID)
return <FcAndroidOs className={"text-white text-xl min-w-[20px]"} />;
return <FcLinux className={"text-white text-lg min-w-[20px]"} />;
}

View File

@@ -24,13 +24,21 @@ export default function TextWithTooltip({
<FullTooltip
disabled={charCount <= maxChars || hideTooltip}
interactive={false}
className={"truncate w-full min-w-0"}
content={
<div className={"max-w-xs break-all whitespace-normal"}>{text}</div>
<div className={"max-w-xs break-all whitespace-normal text-xs"}>
{text}
</div>
}
>
<span className={cn(className)}>
{charCount > maxChars ? text && `${text.slice(0, maxChars)}...` : text}
</span>
<div
className={"w-full min-w-0 inline-block"}
style={{
maxWidth: `${maxChars - 2}ch`,
}}
>
<div className={cn(className, "truncate")}>{text}</div>
</div>
</FullTooltip>
);
}

View File

@@ -36,6 +36,7 @@ export default function UserDropdown() {
useHotkeys("shift+mod+l", () => logout(), []);
const [dropdownOpen, setDropdownOpen] = useState(false);
const { permission } = useLoggedInUser();
return (
<DropdownMenu
@@ -67,19 +68,23 @@ export default function UserDropdown() {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
if (loggedInUser) {
router.push(`/team/user?id=${loggedInUser.id}`);
}
}}
>
<div className={"flex gap-3 items-center"}>
<User2 size={14} />
Profile Settings
</div>
</DropdownMenuItem>
{permission.dashboard_view !== "blocked" && (
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
if (loggedInUser) {
router.push(`/team/user?id=${loggedInUser.id}`);
}
}}
>
<div className={"flex gap-3 items-center"}>
<User2 size={14} />
Profile Settings
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={logoutSession}>
<div className={"flex gap-3 items-center"}>
<LogOutIcon size={14} />

View File

@@ -19,6 +19,7 @@ const AnalyticsContext = React.createContext(
{} as {
initialized: boolean;
trackPageView: () => void;
trackEvent: (category: string, action: string, label: string) => void;
},
);
const config = loadConfig();
@@ -51,8 +52,20 @@ export default function AnalyticsProvider({ children }: Props) {
ReactGA.send({ hitType: "pageview", page: path, title: document.title });
};
const trackEvent = (category: string, action: string, label: string) => {
if (isProduction() && ReactGA.isInitialized) {
ReactGA.event({
category: category,
action: action,
label: label,
});
}
};
return (
<AnalyticsContext.Provider value={{ initialized, trackPageView }}>
<AnalyticsContext.Provider
value={{ initialized, trackPageView, trackEvent }}
>
{children}
</AnalyticsContext.Provider>
);

View File

@@ -0,0 +1,103 @@
import { AnnouncementVariant } from "@components/ui/AnnouncementBanner";
import { useLocalStorage } from "@hooks/useLocalStorage";
import md5 from "crypto-js/md5";
import React, { useEffect, useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
const initialAnnouncements: Announcement[] = [];
export interface Announcement extends AnnouncementVariant {
tag: string;
text: string;
link?: string;
linkText?: string;
isExternal?: boolean;
closeable: boolean;
isCloudOnly: boolean;
}
interface AnnouncementInfo extends Announcement {
isOpen: boolean;
hash: string;
}
type Props = {
children: React.ReactNode;
};
const AnnouncementContext = React.createContext(
{} as {
bannerHeight: number;
announcements?: AnnouncementInfo[];
closeAnnouncement: (hash: string) => void;
setAnnouncements: React.Dispatch<
React.SetStateAction<AnnouncementInfo[] | undefined>
>;
},
);
const bannerHeight = 40;
export default function AnnouncementProvider({ children }: Props) {
const [height, setHeight] = useState(0);
const [closedAnnouncements, setClosedAnnouncements] = useLocalStorage<
string[]
>("netbird-closed-announcements", []);
const [announcements, setAnnouncements] = useState<AnnouncementInfo[]>();
const { permission } = useLoggedInUser();
useEffect(() => {
if (announcements && announcements.length > 0) return;
if (permission?.dashboard_view === "blocked") return;
const initial = initialAnnouncements.map((announcement) => {
const hash = md5(announcement.text).toString();
const isOpen = !closedAnnouncements.some((h) => h === hash);
return {
...announcement,
hash,
isOpen,
} as AnnouncementInfo;
});
if (initial.length > 0) {
setAnnouncements(initial);
}
}, [closedAnnouncements, announcements]);
const closeAnnouncement = (hash: string) => {
setClosedAnnouncements([...closedAnnouncements, hash]);
setAnnouncements(() => {
return announcements?.map((a) => {
if (a.hash === hash) {
return { ...a, isOpen: false };
}
return a;
});
});
};
useEffect(() => {
const isAnnouncementOpen = announcements?.some((a) => a.isOpen);
if (isAnnouncementOpen) {
setHeight(bannerHeight);
} else {
setHeight(0);
}
}, [announcements]);
return (
<AnnouncementContext.Provider
value={{
bannerHeight: height,
announcements,
closeAnnouncement,
setAnnouncements,
}}
>
{children}
</AnnouncementContext.Provider>
);
}
export const useAnnouncement = () => {
return React.useContext(AnnouncementContext);
};

View File

@@ -3,7 +3,14 @@ import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useApiCall } from "@utils/api";
import { useIsMd } from "@utils/responsive";
import { getLatestNetbirdRelease } from "@utils/version";
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { User } from "@/interfaces/User";
import type { NetbirdRelease } from "@/interfaces/Version";
@@ -32,13 +39,27 @@ export default function ApplicationProvider({ children }: Props) {
const userRequest = useApiCall<User[]>("/users", true);
const [show, setShow] = useState(false);
const requestCalled = useRef(false);
const maxTries = 3;
const populateCache = useCallback(
async (tries = 0) => {
if (tries >= maxTries) {
setShow(true);
return Promise.reject();
}
try {
await userRequest.get().then(() => setShow(true));
return Promise.resolve();
} catch (e) {
setTimeout(() => populateCache(tries + 1), 500);
}
},
[userRequest, setShow],
);
useEffect(() => {
if (!requestCalled.current) {
userRequest
.get()
.then(() => setShow(true))
.catch(() => setShow(true));
populateCache().then();
requestCalled.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -0,0 +1,64 @@
import useFetchApi from "@utils/api";
import React, { useCallback } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Country } from "@/interfaces/Country";
import { Peer } from "@/interfaces/Peer";
type Props = {
children: React.ReactNode;
};
const CountryContext = React.createContext(
{} as {
countries: Country[] | undefined;
isLoading: boolean;
getRegionByPeer: (peer: Peer) => string;
},
);
export default function CountryProvider({ children }: Props) {
const { permission } = useLoggedInUser();
const getRegionByPeer = (peer: Peer) => "Unknown";
return permission?.dashboard_view != "full" ? (
<CountryContext.Provider
value={{ countries: [], isLoading: false, getRegionByPeer }}
>
{children}
</CountryContext.Provider>
) : (
<CountryProviderContent>{children}</CountryProviderContent>
);
}
function CountryProviderContent({ children }: Props) {
const { data: countries, isLoading } = useFetchApi<Country[]>(
"/locations/countries",
true,
false,
);
const getRegionByPeer = useCallback(
(peer: Peer) => {
if (!countries) return "Unknown";
const country = countries.find(
(c) => c.country_code === peer.country_code,
);
if (!country) return "Unknown";
if (!peer.city_name) return country.country_name;
return `${country.country_name}, ${peer.city_name}`;
},
[countries],
);
return (
<CountryContext.Provider value={{ countries, isLoading, getRegionByPeer }}>
{children}
</CountryContext.Provider>
);
}
export const useCountries = () => {
return React.useContext(CountryContext);
};

View File

@@ -81,16 +81,20 @@ export default function DialogProvider({ children }: Props) {
/>
{dialogOptions.children && (
<div className={"px-8 pt-4"}>{dialogOptions.children}</div>
<div className={"px-8 pt-0"}>{dialogOptions.children}</div>
)}
<ModalFooter className={"items-center gap-2"} separator={false}>
<ModalFooter
className={"items-center gap-2 pt-5"}
separator={false}
>
<ModalClose asChild={true}>
<Button
variant={"secondary"}
className={"w-full"}
size={"sm"}
tabIndex={-1}
data-cy={"confirmation.cancel"}
onClick={() => fn.current && fn.current(false)}
>
{dialogOptions.cancelText || "Cancel"}
@@ -106,6 +110,7 @@ export default function DialogProvider({ children }: Props) {
}
className={"w-full"}
size={"sm"}
data-cy={"confirmation.confirm"}
onClick={() => fn.current && fn.current(true)}
>
{dialogOptions.confirmText || "Confirm"}

View File

@@ -1,5 +1,7 @@
import useFetchApi from "@utils/api";
import { usePathname } from "next/navigation";
import React, { useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Group } from "@/interfaces/Group";
type Props = {
@@ -12,20 +14,38 @@ const GroupContext = React.createContext(
refresh: () => void;
dropdownOptions: Group[];
setDropdownOptions: React.Dispatch<React.SetStateAction<Group[]>>;
isLoading: boolean;
},
);
export default function GroupsProvider({ children }: Props) {
const { data: groups, mutate } = useFetchApi<Group[]>("/groups");
const path = usePathname();
const { permission } = useLoggedInUser();
return path === "/peers" && permission.dashboard_view == "blocked" ? (
<>{children}</>
) : (
<GroupsProviderContent>{children}</GroupsProviderContent>
);
}
export function GroupsProviderContent({ children }: Props) {
const { data: groups, mutate, isLoading } = useFetchApi<Group[]>("/groups");
const [dropdownOptions, setDropdownOptions] = useState<Group[]>([]);
const refresh = () => {
mutate().then();
if (groups && !isLoading) mutate().then();
};
return (
<GroupContext.Provider
value={{ groups, refresh, dropdownOptions, setDropdownOptions }}
value={{
groups,
refresh,
dropdownOptions,
setDropdownOptions,
isLoading,
}}
>
{children}
</GroupContext.Provider>

View File

@@ -1,4 +1,5 @@
import { notify } from "@components/Notification";
import SkeletonPeerDetail from "@components/skeletons/SkeletonPeerDetail";
import { useApiCall } from "@utils/api";
import React, { useMemo } from "react";
import { useSWRConfig } from "swr";
@@ -27,12 +28,13 @@ const PeerContext = React.createContext(
) => Promise<Peer>;
openSSHDialog: () => Promise<boolean>;
deletePeer: () => void;
isLoading: boolean;
},
);
export default function PeerProvider({ children, peer }: Props) {
const user = usePeerUser(peer);
const peerGroups = usePeerGroups(peer);
const { peerGroups, isLoading } = usePeerGroups(peer);
const peerRequest = useApiCall<Peer>("/peers");
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
@@ -75,9 +77,7 @@ export default function PeerProvider({ children, peer }: Props) {
? loginExpiration
: peer.login_expiration_enabled,
approval_required:
approval_required != undefined
? approval_required
: peer.approval_required,
approval_required == undefined ? undefined : approval_required,
},
`/${peer.id}`,
);
@@ -94,12 +94,22 @@ export default function PeerProvider({ children, peer }: Props) {
});
};
return (
return !isLoading ? (
<PeerContext.Provider
value={{ peer, peerGroups, user, update, openSSHDialog, deletePeer }}
value={{
peer,
peerGroups,
user,
update,
openSSHDialog,
deletePeer,
isLoading,
}}
>
{children}
</PeerContext.Provider>
) : (
<SkeletonPeerDetail />
);
}
@@ -108,9 +118,9 @@ export default function PeerProvider({ children, peer }: Props) {
* @param peer
*/
export const usePeerGroups = (peer?: Peer) => {
const { groups } = useGroups();
const { groups, isLoading } = useGroups();
return useMemo(() => {
const peerGroups = useMemo(() => {
if (!peer) return [];
const peerGroups = groups?.filter((group) => {
const foundGroup = group.peers?.find((p) => {
@@ -121,6 +131,8 @@ export const usePeerGroups = (peer?: Peer) => {
});
return peerGroups || [];
}, [groups, peer]);
return { peerGroups, isLoading };
};
/**

View File

@@ -1,5 +1,5 @@
import useFetchApi from "@utils/api";
import React from "react";
import React, { useMemo } from "react";
import type { Peer } from "@/interfaces/Peer";
type Props = {
@@ -9,15 +9,21 @@ type Props = {
const PeerContext = React.createContext(
{} as {
peers: Peer[] | undefined;
isLoading: boolean;
},
);
export default function PeersProvider({ children }: Props) {
const { data: peers } = useFetchApi<Peer[]>("/peers");
export default function PeersProvider({ children }: Readonly<Props>) {
const { data: peers, isLoading } = useFetchApi<Peer[]>("/peers");
return (
<PeerContext.Provider value={{ peers }}>{children}</PeerContext.Provider>
);
const data = useMemo(() => {
return {
peers,
isLoading,
};
}, [peers, isLoading]);
return <PeerContext.Provider value={data}>{children}</PeerContext.Provider>;
}
export const usePeers = () => React.useContext(PeerContext);

View File

@@ -25,7 +25,7 @@ const RoutesContext = React.createContext(
);
export default function RoutesProvider({ children }: Props) {
const routeRequest = useApiCall<Route>("/routes");
const routeRequest = useApiCall<Route>("/routes", true);
const { mutate } = useSWRConfig();
const updateRoute = async (
@@ -34,6 +34,8 @@ export default function RoutesProvider({ children }: Props) {
onSuccess?: (route: Route) => void,
message?: string,
) => {
const hasDomains = route.domains ? route.domains.length > 0 : false;
notify({
title: "Network " + route.network_id + "-" + route.network,
description: message
@@ -48,7 +50,9 @@ export default function RoutesProvider({ children }: Props) {
peer: toUpdate.peer ?? (route.peer || undefined),
peer_groups:
toUpdate.peer_groups ?? (route.peer_groups || undefined),
network: route.network,
network: !hasDomains ? route.network : undefined,
domains: hasDomains ? route.domains : undefined,
keep_route: route.keep_route,
metric: toUpdate.metric ?? route.metric ?? 9999,
masquerade: toUpdate.masquerade ?? route.masquerade ?? true,
groups: toUpdate.groups ?? route.groups ?? [],
@@ -80,7 +84,9 @@ export default function RoutesProvider({ children }: Props) {
enabled: route.enabled,
peer: route.peer || undefined,
peer_groups: route.peer_groups || undefined,
network: route.network,
network: route?.network || undefined,
domains: route?.domains || undefined,
keep_route: route?.keep_route || false,
metric: route.metric || 9999,
masquerade: route.masquerade,
groups: route.groups || [],

View File

@@ -1,6 +1,7 @@
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useFetchApi from "@utils/api";
import React, { useMemo } from "react";
import { Permission } from "@/interfaces/Permission";
import { User } from "@/interfaces/User";
type Props = {
@@ -26,7 +27,7 @@ export default function UsersProvider({ children }: Props) {
return users?.find((user) => user.is_current);
}, [users]);
return !isLoading ? (
return !isLoading && loggedInUser ? (
<UsersContext.Provider value={{ users, refresh, loggedInUser }}>
{children}
</UsersContext.Provider>
@@ -43,5 +44,19 @@ export const useLoggedInUser = () => {
const isAdmin = loggedInUser ? loggedInUser?.role === "admin" : false;
const isUser = !isOwner && !isAdmin;
const isOwnerOrAdmin = isOwner || isAdmin;
return { loggedInUser, isOwner, isAdmin, isUser, isOwnerOrAdmin } as const;
const permission = useMemo(() => {
return {
dashboard_view: loggedInUser?.permissions.dashboard_view || "blocked",
} as Permission;
}, [loggedInUser]);
return {
loggedInUser,
isOwner,
isAdmin,
isUser,
isOwnerOrAdmin,
permission,
} as const;
};

View File

@@ -2,6 +2,10 @@
import { OperatingSystem } from "@/interfaces/OperatingSystem";
/**
* Get the operating system of the user based on the user agent of the browser
* This is used for the setup modal to show the correct installation guide
*/
export default function useOperatingSystem() {
const isBrowser = typeof window !== "undefined";
const userAgent = isBrowser ? navigator.userAgent.toLowerCase() : "";
@@ -9,14 +13,27 @@ export default function useOperatingSystem() {
? /(iP*)/g.test(navigator.userAgent) && navigator.maxTouchPoints > 2
: false;
if (iOS) return OperatingSystem.IOS;
// For FreeBSD, we return Linux as we currently don't have an official installation guide for FreeBSD
if (userAgent.toLowerCase().includes("freebsd")) return OperatingSystem.LINUX;
return getOperatingSystem(userAgent);
}
/**
* Get the operating system based on a string (user agent, api response, etc.)
* Falls back to Linux if the operating system is not recognized
*/
export const getOperatingSystem = (os: string) => {
if (os.includes("darwin")) return OperatingSystem.APPLE as const;
if (os.includes("mac")) return OperatingSystem.APPLE as const;
if (os.includes("android")) return OperatingSystem.ANDROID as const;
if (os.includes("ios")) return OperatingSystem.IOS as const;
if (os.includes("win")) return OperatingSystem.WINDOWS as const;
if (os.toLowerCase().includes("freebsd"))
return OperatingSystem.FREEBSD as const;
if (os.toLowerCase().includes("darwin"))
return OperatingSystem.APPLE as const;
if (os.toLowerCase().includes("mac")) return OperatingSystem.APPLE as const;
if (os.toLowerCase().includes("android"))
return OperatingSystem.ANDROID as const;
if (os.toLowerCase().includes("ios")) return OperatingSystem.IOS as const;
if (os.toLowerCase().includes("ipad")) return OperatingSystem.IOS as const;
if (os.toLowerCase().includes("iphone")) return OperatingSystem.IOS as const;
if (os.toLowerCase().includes("windows"))
return OperatingSystem.WINDOWS as const;
return OperatingSystem.LINUX as const;
};

View File

@@ -0,0 +1,12 @@
import { useLayoutEffect, useRef, useState } from "react";
export function usePortalElement<Element>() {
const ref = useRef<Element>(null);
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
useLayoutEffect(() => {
setPortalTarget(ref.current);
}, []);
return { ref, portalTarget, setPortalTarget };
}

13
src/hooks/usePrevious.ts Normal file
View File

@@ -0,0 +1,13 @@
import { useEffect, useRef } from "react";
const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
};
export default usePrevious;

View File

@@ -1,8 +1,9 @@
import loadConfig from "@utils/config";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
const config = loadConfig();
export const useRedirect = (
url: string,
replace: boolean = false,
@@ -10,24 +11,43 @@ export const useRedirect = (
) => {
const router = useRouter();
const currentPath = usePathname();
const callBackUrls = [config.redirectURI, config.silentRedirectURI];
const callBackUrls = useRef([config.redirectURI, config.silentRedirectURI]);
const isRedirecting = useRef(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!enable) return;
if (callBackUrls.includes(url)) return; // Don't redirect to the callback urls to avoid infinite loop
if (url === currentPath) return; // Don't redirect to the current page
// If redirect is disabled or the url is already in the callback urls or the url is the current path then do not redirect
if (!enable || callBackUrls.current.includes(url) || url === currentPath)
return;
const redirect = replace ? router.replace : router.push; // Replace the current history or add a new one
const performRedirect = () => {
if (!isRedirecting.current) {
isRedirecting.current = true;
router.refresh();
if (replace) {
router.replace(url);
} else {
router.push(url);
}
isRedirecting.current = false;
}
};
router.refresh();
redirect(url);
performRedirect();
// Timer in case the user has his browser tab open but not focused
const interval = setInterval(() => {
router.refresh();
redirect(url);
}, 1000);
// Try to redirect after 1.25 seconds if for whatever reason the redirect did not happen (network change, browser tab open but not focused etc.)
intervalRef.current = setInterval(() => {
if (!isRedirecting.current) {
performRedirect();
}
}, 1250);
return () => clearInterval(interval);
}, [replace, router, url, enable]);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [replace, router, url, enable, currentPath]);
};
export default useRedirect;

91
src/hooks/useSearch.ts Normal file
View File

@@ -0,0 +1,91 @@
import { debounce as lodashDebounce, isEqual } from "lodash";
import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
import usePrevious from "./usePrevious";
export type Predicate<T> = (item: T, query: string) => boolean;
export interface Options {
initialQuery?: string;
filter?: boolean;
debounce?: number;
}
function filterCollection<T>(
collection: T[],
predicate: Predicate<T>,
query: string,
filter: boolean,
): T[] {
if (query) {
return collection.filter((item) => predicate(item, query));
} else {
return filter ? collection : [];
}
}
export function useSearch<T>(
collection: T[],
predicate: Predicate<T>,
{ debounce, filter = false, initialQuery = "" }: Options = {},
): [
T[],
string,
(event: ChangeEvent<HTMLInputElement> | string) => void,
(querty: string) => void,
] {
const isMounted = useRef<boolean>(false);
const [query, setQuery] = useState<string>(initialQuery);
const prevCollection = usePrevious(collection);
const prevPredicate = usePrevious(predicate);
const prevQuery = usePrevious(query);
const prevFilter = usePrevious(filter);
const [filteredCollection, setFilteredCollection] = useState<T[]>(() =>
filterCollection<T>(collection, predicate, query, filter),
);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement> | string) => {
setQuery(typeof event === "string" ? event : event.target.value);
},
[setQuery],
);
const debouncedFilterCollection = useCallback(
lodashDebounce(
(
collection: T[],
predicate: Predicate<T>,
query: string,
filter: boolean,
) => {
if (isMounted.current) {
setFilteredCollection(
filterCollection(collection, predicate, query, filter),
);
}
},
debounce,
),
[debounce],
);
useEffect(() => {
if (
!isEqual(collection, prevCollection) ||
!isEqual(predicate, prevPredicate) ||
!isEqual(query, prevQuery) ||
!isEqual(filter, prevFilter)
)
debouncedFilterCollection(collection, predicate, query, filter);
}, [collection, predicate, query, filter]);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return [filteredCollection, query, handleChange, setQuery];
}

View File

@@ -10,5 +10,6 @@ export interface Account {
jwt_groups_enabled: boolean;
jwt_groups_claim_name: string;
jwt_allow_groups: string[];
regular_users_view_blocked: boolean;
};
}

4
src/interfaces/Domain.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Domain {
id?: string;
name: string;
}

View File

@@ -17,6 +17,14 @@ export interface AzureADIntegration {
user_group_prefixes: string[];
}
export interface OktaIntegration {
id: string;
enabled: boolean;
group_prefixes: string[];
user_group_prefixes: string[];
auth_token: string;
}
export interface IdentityProviderLog {
id: number;
level: string;

View File

@@ -17,11 +17,6 @@ export interface Nameserver {
id?: string;
}
export interface Domain {
id?: string;
name: string;
}
export const NameserverPresets: Record<string, NameserverGroup> = {
Default: {
name: "",

View File

@@ -6,4 +6,5 @@ export enum OperatingSystem {
DOCKER,
IOS,
UNKNOWN,
FREEBSD,
}

View File

@@ -20,4 +20,7 @@ export interface Peer {
login_expired: boolean;
login_expiration_enabled: boolean;
approval_required: boolean;
city_name: string;
country_code: string;
connection_ip: string;
}

View File

@@ -0,0 +1,3 @@
export interface Permission {
dashboard_view: "limited" | "full" | "blocked";
}

View File

@@ -9,6 +9,8 @@ export interface PostureCheck {
nb_version_check?: NetBirdVersionCheck;
os_version_check?: OperatingSystemVersionCheck;
geo_location_check?: GeoLocationCheck;
peer_network_range_check?: PeerNetworkRangeCheck;
process_check?: ProcessCheck;
};
policies?: Policy[];
active?: boolean;
@@ -47,6 +49,22 @@ export interface GeoLocation {
city_name: string;
}
export interface PeerNetworkRangeCheck {
ranges: string[];
action: "allow" | "deny";
}
export interface ProcessCheck {
processes: Process[];
}
export interface Process {
id: string;
linux_path?: string;
mac_path?: string;
windows_path?: string;
}
export const windowsKernelVersions: SelectOption[] = [
{ value: "5.0", label: "Windows 2000" },
{ value: "5.1", label: "Windows XP" },

View File

@@ -3,26 +3,34 @@ export interface Route {
description: string;
enabled: boolean;
peer?: string;
network: string;
network?: string;
domains?: string[];
network_id: string;
network_type?: string;
metric?: number;
masquerade: boolean;
groups: string[];
keep_route?: boolean;
// Frontend only
peer_groups?: string[];
routesGroups?: string[];
groupedRoutes?: GroupedRoute[];
group_names?: string[];
domain_search?: string;
}
export interface GroupedRoute {
id: string;
enabled: boolean;
network: string;
network?: string;
domains?: string[];
keep_route?: boolean;
network_id: string;
high_availability_count: number;
is_using_route_groups: boolean;
routes?: Route[];
group_names?: string[];
description?: string;
description_search?: string;
domain_search?: string;
}

View File

@@ -1,3 +1,5 @@
import { Permission } from "@/interfaces/Permission";
export interface User {
id: string;
email?: string;
@@ -9,6 +11,7 @@ export interface User {
is_service_user?: boolean;
is_blocked?: boolean;
last_login?: Date;
permissions: Permission;
}
export enum Role {

View File

@@ -6,7 +6,7 @@ import { cn } from "@utils/helpers";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { Viewport } from "next/dist/lib/metadata/types/extra-types";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import React from "react";
import { Toaster } from "react-hot-toast";
import OIDCProvider from "@/auth/OIDCProvider";
@@ -16,7 +16,10 @@ import ErrorBoundaryProvider from "@/contexts/ErrorBoundary";
import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider";
import { NavigationEvents } from "@/contexts/NavigationEvents";
const inter = Inter({ subsets: ["latin"] });
const inter = localFont({
src: "../assets/fonts/Inter.ttf",
display: "swap",
});
// Extend dayjs with relativeTime plugin
dayjs.extend(relativeTime);

View File

@@ -9,13 +9,17 @@ import { useIsSm, useIsXs } from "@utils/responsive";
import { AnimatePresence, motion } from "framer-motion";
import { XIcon } from "lucide-react";
import React from "react";
import AnnouncementProvider, {
useAnnouncement,
} from "@/contexts/AnnouncementProvider";
import ApplicationProvider, {
useApplicationContext,
} from "@/contexts/ApplicationProvider";
import CountryProvider from "@/contexts/CountryProvider";
import GroupsProvider from "@/contexts/GroupsProvider";
import UsersProvider from "@/contexts/UsersProvider";
import UsersProvider, { useLoggedInUser } from "@/contexts/UsersProvider";
import Navigation from "@/layouts/Navigation";
import Navbar from "./Header";
import Navbar, { headerHeight } from "./Header";
export default function DashboardLayout({
children,
@@ -25,9 +29,13 @@ export default function DashboardLayout({
return (
<ApplicationProvider>
<UsersProvider>
<GroupsProvider>
<DashboardPageContent>{children}</DashboardPageContent>
</GroupsProvider>
<AnnouncementProvider>
<GroupsProvider>
<CountryProvider>
<DashboardPageContent>{children}</DashboardPageContent>
</CountryProvider>
</GroupsProvider>
</AnnouncementProvider>
</UsersProvider>
</ApplicationProvider>
);
@@ -38,9 +46,10 @@ function DashboardPageContent({ children }: { children: React.ReactNode }) {
const { mobileNavOpen, toggleMobileNav } = useApplicationContext();
const isSm = useIsSm();
const isXs = useIsXs();
const { permission } = useLoggedInUser();
const navOpenPageWidth = isSm ? "50%" : isXs ? "65%" : "80%";
const { bannerHeight } = useAnnouncement();
return (
<div className={cn("flex flex-col h-screen", mobileNavOpen && "flex")}>
{mobileNavOpen && (
@@ -142,13 +151,16 @@ function DashboardPageContent({ children }: { children: React.ReactNode }) {
}}
>
<Navbar />
<div
className={"flex flex-row flex-grow"}
style={{
height: "calc(100vh - 75px)",
height: `calc(100vh - ${headerHeight + bannerHeight}px)`,
}}
>
<Navigation hideOnMobile />
{permission.dashboard_view !== "blocked" && (
<Navigation hideOnMobile />
)}
{children}
</div>
</motion.div>

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