Compare commits

...

49 Commits

Author SHA1 Message Date
Eduard Gert
f5824d6ddb Allow empty groups for reverse proxy sso auth (#563)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-18 16:27:13 +01:00
Eduard Gert
829395f908 Add hover to reverse proxy auth methods (#564) 2026-02-18 13:39:19 +01:00
Eduard Gert
8eebec78b4 Preserve query params for ssh and rdp (#559)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-16 17:34:08 +01:00
raghvendra
3e01a6dafd refactor: simplify FullScreenLoading to use boolean prop instead of string union (#555) 2026-02-16 11:10:26 +01:00
Maycon Santos
1555b94043 Fix service cluster status (#556)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-16 09:23:22 +01:00
Eduard Gert
6c62127d42 Update announcement (#553) 2026-02-13 20:56:40 +01:00
Eduard Gert
b71d0fde89 Add reverse proxy (#552)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* **New Features**
  * Full Reverse Proxy UI: Services, Targets, Clusters, Custom Domains (with verification) and a Proxy Events page.
  * In-app modals for service auth (SSO, password, PIN) and a new PIN input component.

* **Improvements**
  * Network & Peer pages: tabbed views (Resources, Routing Peers, Services) and improved tables, search and filters.
  * Toast stacking/visibility and global toast styling refined.
2026-02-13 18:59:16 +01:00
Misha Bragin
84c239ce30 Indicate that local user auth is disabled (#551) 2026-02-12 15:16:34 +01:00
Aaron Dewes
ba66201c64 Remove architecture info tooltip for MacOS (#550)
* Remove architecture info tooltip for MacOS

Previously, this tooltip helped users determine which binary to download. Since #501, there is only one universal binary download link, so keeping the tooltip explaining how to determine the CPU architecture is unnecessary.

* fix: Remove unused imports
2026-02-12 11:21:08 +01:00
raghvendra
c6341e000f docs: fix broken Auth0 quickstart link in README (#548)
* docs: fix broken Auth0 quickstart link

* docs: spell error fixes in readme

* docs: fix typo in NETBIRD_MGMT_API_ENDPOINT placeholder in readme
2026-02-09 11:41:09 +08:00
Eduard Gert
750f660bcc Update NextJS to 16.1.6 (#547)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Update NextJS to 16.1.6

* Update Node in workflow

* Fix rabbit comments

* Fix types

* Add engines field
2026-02-02 15:34:23 +01:00
Misha Bragin
ea148545e8 Disable local users when LocalAuthDisabled = true (#546)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-01 14:31:57 +01:00
Misha Bragin
d2febbf27b Fix version comparison (#544)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 14:13:27 +01:00
Misha Bragin
615b4487ad Point to the right upgrade doc (#543)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 12:23:56 +01:00
Misha Bragin
a7c7800916 Add invite notification count badge (#542) 2026-01-27 10:44:39 +01:00
Eduard Gert
3d51e0893e Update announcement (#538)
* Update announcement

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

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

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

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

* update job ui

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

* update job event description

---------

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

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

* Fix bad state while redirect

* Prevent redirect to /setup if already on /setup

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

* Add initial identity provider view

* Add IdP logos

* Add IdP id to user

* Add IdP logo to user obj

* Fix okta icon

* Return callback URL when creating an IdP

* Create user for self-hosted

* Clear up password from the state

* Show IdPs and create user when enabled

* Fetch IdPs only when embedded idp is enabled

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

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

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

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

* Update src/modules/settings/IdentityProvidersTab.tsx

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

* Update src/modules/settings/IdentityProviderModal.tsx

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

* Update src/modules/settings/IdentityProvidersTab.tsx

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

* Update src/modules/settings/IdentityProviderModal.tsx

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

* Rename IdentityProvider to SSOIdentityProvider

* Fix build and extract icons

* Fix initial onboarding

* Add icons

* Move name to the top

* Fix setup wizard background color

* Update instance setup ui

* Update instance setup ui

* Use input component

* Move idp label and icons

* Fix setup wizard width

* Add authentik and keycloak

* Add idp hints

* Handle idp permissions

* Consider selfhosted instances when checking if netbird is hosted

* Update redirect

* Add max retries to redirect

* Require new secret when clientid changed

* Add callback URL on the idp creation step

* Add idp activity events

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-01-07 14:43:30 +01:00
Eduard Gert
bf81aeb02d Add fine-grained ssh policy (#522)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add fine-grained ssh policy

* Update version text

* Fix coderabbit comment
2025-12-30 09:27:17 +01:00
Eduard Gert
b058e66e32 Add auto update setting (#519)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-12-29 12:38:50 +01:00
Eduard Gert
8d6b617cbd Update NextJS to 14.2.35 (#518)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-12-22 11:02:29 +01:00
Eduard Gert
47db655e9f Update eslint and tailwind (#515)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-27 17:38:18 +01:00
dependabot[bot]
0661cbf9f4 Bump js-yaml from 4.1.0 to 4.1.1 (#509)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-27 15:25:50 +01:00
Eduard Gert
240a96fa8b Add onboarding for new accounts (#514) 2025-11-27 14:49:58 +01:00
Eduard Gert
43bc069a49 Increase ssh detection timeout (#512)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-21 10:32:50 +01:00
Eduard Gert
936de0f4f3 Add ssh policy info for peers (#511)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-20 14:29:14 +01:00
Eduard Gert
d81b75a946 Bump browser ssh versions for ssh rewrite (#510)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Bump browser ssh versions for ssh rewrite

* Remove cypress temporary
2025-11-18 17:07:58 +01:00
Eduard Gert
a632eeeef0 Remove dns0eu (#508) 2025-11-10 14:21:58 +01:00
Eduard Gert
e2219aeea0 Add group update activity event (#504)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-10 10:50:04 +01:00
Eduard Gert
63f4c69eb4 Fix native ssh detection (#505) 2025-11-07 09:33:58 +01:00
Eduard Gert
b1af256296 Add wasm client version (#503) 2025-11-06 10:59:41 +01:00
Eduard Gert
4027894a2e Feature/groups page (#498)
* move our group membership from the settings menu, into the Team menu

* add action to the table and new group page

* update group page and return group settings to settings menu

* new update

* fix bug

* group action: add peer to group

* group action: add user to group

* Update wording, redirect to group page after creation

* Add better table loading skeleton

* Adjust group name cell

* Update wording

* Update sort order

* Refactor

* Merge main

* Fix button height

* Fix resources table

* Adjust table loading skeleton

* Adjust table loading skeleton

* Add loading to tab triggers

* Update meta

* Update group location

* Fix rename

* Refactor group details

* Fix linked peers

* Fix group usage

* Fix incrementing peer count

* Prevent renaming to already existing group

* Fix group name click

* Update group nav

* Make group table cells clickable

* Fix breadcrumbs

* Update wording

* Add confirmation before removing users from group

* Add permissions

* Add initial group for network routes

* Add acl and routing peer groups

---------

Co-authored-by: aliamerj <aliamer19ali@gmail.com>
2025-11-05 12:08:49 +01:00
Yanis64
af90792595 Add multi-group support for JWT allow groups with tag system (#500)
* fix: add multi-group support for JWT allow groups with tag system

* Update src/modules/settings/GroupsTab.tsx to use the Badge component

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

* chore(GroupsTab): import Badge components

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2025-11-03 16:09:15 +01:00
Eduard Gert
9a401733b3 Fix toggle for p2p policies (#501)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-31 13:21:23 +01:00
Eduard Gert
07b6895380 Sync SSH & RDP changes (#495)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-16 14:44:26 +02:00
Eduard Gert
9e2e38764e Add control center (#494)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add control center

* Update rdp doc link
2025-10-09 11:26:21 +02:00
398 changed files with 34078 additions and 7853 deletions

View File

@@ -2,7 +2,6 @@ name: build and push
on:
push:
branches:
- "feature/**"
- main
tags:
- "**"
@@ -20,7 +19,7 @@ jobs:
- name: setup-node
uses: actions/setup-node@v3
with:
node-version: '18'
node-version: '20'
cache: 'npm'
- name: Install dependencies
@@ -55,8 +54,19 @@ jobs:
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "version=development" >> $GITHUB_OUTPUT
fi
- name: Build
run: npm run build
env:
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2

2
.gitignore vendored
View File

@@ -37,6 +37,8 @@ next-env.d.ts
# config
.local-config.json
.test-config.json
cypress.env.json
.configs/.local-config.zitadel.json
.configs/.staging-config.json
.configs/.temp-config.json

View File

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

View File

@@ -10,6 +10,7 @@ See [NetBird repo](https://github.com/netbirdio/netbird)
The purpose of this project is simple - make it easy to manage VPN built with [NetBird](https://github.com/netbirdio/netbird).
The dashboard makes it possible to:
- track the status of your peers
- remove peers
- manage Setup Keys (to authenticate new peers)
@@ -17,23 +18,25 @@ The dashboard makes it possible to:
- define access controls
## Some Screenshots
<img src="./src/assets/screenshots/peers.png" alt="peers"/>
<img src="./src/assets/screenshots/add-peer.png" alt="add-peer"/>
## Technologies Used
- NextJS
- ReactJS
- Tailwind CSS
- [React Flow](https://reactflow.dev/) for the Control Center
- Auth0
- Nginx
- Docker
- Let's Encrypt
## How to run
Disclaimer. We believe that proper user management system is not a trivial task and requires quite some effort to make it right. Therefore we decided to
use Auth0 service that covers all our needs (user management, social login, JTW for the management API).
use Auth0 service that covers all our needs (user management, social login, JWT for the management API).
Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
1. Install [Docker](https://docs.docker.com/get-docker/)
@@ -42,9 +45,9 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
`AUTH0_DOMAIN` `AUTH0_CLIENT_ID` `AUTH0_AUDIENCE`
To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react/01-login#configure-auth0) up until "Configure Allowed Web Origins"
To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react) up until "Configure Allowed Web Origins"
4. NetBird UI Dashboard uses NetBirds Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
4. NetBird UI Dashboard uses NetBird's Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
5. Run docker container without SSL (Let's Encrypt):
```shell
@@ -53,9 +56,10 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
-e AUTH0_CLIENT_ID=<SET YOUR CLIENT ID> \
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
netbirdio/dashboard:main
```
6. Run docker container with SSL (Let's Encrypt):
```shell
@@ -67,7 +71,7 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
-e AUTH0_CLIENT_ID=<SET YOUR CLEITN ID> \
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
netbirdio/dashboard:main
```
@@ -83,11 +87,11 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
You can start editing by modifying the code inside `src/..`
The page auto-updates as you edit the file.
## How to migrate from old dashboard (v1)
## How to migrate from old dashboard (v1)
The new dashboard comes with a new docker image `netbirdio/dashboard:main`.
To migrate from the old dashboard (v1) `wiretrustee/dashboard:main` to the new one, please follow the steps below.
1. Stop the dashboard container `docker compose down dashboard`
2. Replace the docker image name in your `docker-compose.yml` with `netbirdio/dashboard:main`
3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard`
3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard`

12
announcements.json Normal file
View File

@@ -0,0 +1,12 @@
[
{
"tag": "New",
"text": "NetBird Reverse Proxy - Expose internal services to the public with automatic TLS and optional authentication.",
"link": "https://docs.netbird.io/manage/reverse-proxy",
"linkText": "Learn more",
"variant": "important",
"isExternal": true,
"closeable": true,
"isCloudOnly": false
}
]

View File

@@ -13,5 +13,6 @@
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID"
}
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
"wasmPath": "$NETBIRD_WASM_PATH"
}

View File

@@ -7,6 +7,10 @@ server {
root /usr/share/nginx/html;
default_type application/wasm;
}
location = /ironrdp-pkg/ironrdp_web_bg.wasm {
root /usr/share/nginx/html;
default_type application/wasm;
}
location / {
try_files $uri $uri.html $uri/ =404;

View File

@@ -61,11 +61,12 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH}
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
# replace ENVs in the config
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS"
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH"
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"

View File

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

9757
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,9 @@
"name": "netbird-dashboard",
"version": "2.0.0",
"private": true,
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
@@ -13,68 +16,73 @@
"cypress:open": "cypress open"
},
"dependencies": {
"@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",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@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",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tabler/icons-react": "^2.39.0",
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.36.1",
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "^4.14.200",
"@types/node": "20.10.6",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.8.4",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"cmdk": "^1.1.1",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"eslint": "^8",
"elkjs": "^0.10.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"flowbite": "^1.8.1",
"flowbite-react": "^0.6.4",
"framer-motion": "^10.16.4",
"framer-motion": "^12.29.2",
"ip-address": "^10.1.0",
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.481.0",
"next": "^14.2.28",
"lodash": "^4.17.23",
"lucide-react": "^0.562.0",
"next": "^16.1.6",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^18.3.1",
"react-day-picker": "^8.9.1",
"react-dom": "^18.3.1",
"react": "^19.2.4",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.4",
"react-ga4": "^2.1.0",
"react-hot-toast": "^2.4.1",
"react-hotjar": "^6.2.0",
"react-hotjar": "^6.3.1",
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^5.5.0",
"react-jwt": "^1.2.0",
"react-loading-skeleton": "^3.3.1",
"react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0",
"sonner": "^2.0.7",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
@@ -85,10 +93,10 @@
"@faker-js/faker": "^9.5.1",
"@types/chroma-js": "^3.1.1",
"@types/js-cookie": "^3.0.6",
"eslint-config-next": "^14.2.28",
"cypress": "^13.13.0",
"eslint": "^9.39.1",
"eslint-config-next": "^16.1.6",
"postcss": "^8",
"prettier": "3.0.3",
"tailwindcss": "^3"
"tailwindcss": "^3.4.17"
}
}

View File

@@ -6,5 +6,5 @@ import React from "react";
export default function Redirect() {
useRedirect("/events/audit");
return <FullScreenLoading height={"auto"} />;
return <FullScreenLoading fullScreen={false} />;
}

View File

@@ -33,7 +33,7 @@ export default function AccessControlPage() {
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/policies"}
href={"/access-control"}
label={"Access Control"}
icon={<AccessControlIcon size={14} />}
/>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon, ServerIcon } from "lucide-react";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import DNSIcon from "@/assets/icons/DNSIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -15,7 +15,7 @@ import { NameserverGroup } from "@/interfaces/Nameserver";
import PageContainer from "@/layouts/PageContainer";
const NameserverGroupTable = lazy(
() => import("@/modules/dns-nameservers/table/NameserverGroupTable"),
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
);
export default function NameServers() {
@@ -32,7 +32,7 @@ export default function NameServers() {
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/dns"}
href={"/dns/nameservers"}
label={"DNS"}
icon={<DNSIcon size={13} />}
/>
@@ -40,7 +40,7 @@ export default function NameServers() {
href={"/dns/nameservers"}
label={"Nameservers"}
active
icon={<ServerIcon size={13} />}
icon={<DNSIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Nameservers</h1>

View File

@@ -11,5 +11,5 @@ export default function DNS() {
router.push("/dns/nameservers");
}, [router]);
return <FullScreenLoading height={"auto"} />;
return <FullScreenLoading fullScreen={false} />;
}

View File

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

View File

@@ -0,0 +1,70 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import DNSIcon from "@/assets/icons/DNSIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS";
import PageContainer from "@/layouts/PageContainer";
import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider";
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
const DNSZonesTable = lazy(
() => import("@/modules/dns/zones/table/DNSZonesTable"),
);
export default function DNSZonePage() {
const { permission } = usePermissions();
const { data: zones, isLoading } = useFetchApi<DNSZone[]>("/dns/zones");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item label={"DNS"} icon={<DNSIcon size={13} />} />
<Breadcrumbs.Item
href={"/dns/zones"}
label={"Zones"}
active
icon={<DNSZoneIcon size={16} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Zones</h1>
<Paragraph>
Manage DNS zones to control domain name resolution for your network.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
DNS Zones
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess page={"DNS Zones"} hasAccess={permission?.dns?.read}>
<Suspense fallback={<SkeletonTable />}>
<DNSZonesProvider>
<DNSZonesTable
isLoading={isLoading}
headingTarget={portalTarget}
data={zones}
/>
</DNSZonesProvider>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import dayjs from "dayjs";
import { ExternalLinkIcon } from "lucide-react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import React, { useMemo } from "react";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import ServerPaginationProvider from "@/contexts/ServerPaginationProvider";
import PageContainer from "@/layouts/PageContainer";
import ReverseProxyEventsTable from "@/modules/reverse-proxy/events/ReverseProxyEventsTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { REVERSE_PROXY_EVENTS_DOCS_LINK } from "@/interfaces/ReverseProxy";
export default function ProxyEventsPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const defaultFilters = useMemo(
() => ({
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
end_date: dayjs().endOf("day").toISOString(),
}),
[],
);
return (
<PageContainer>
<div className="p-default py-6">
<Breadcrumbs>
<Breadcrumbs.Item
label="Activity"
disabled
icon={<ActivityIcon size={13} />}
/>
<Breadcrumbs.Item
href="/events/proxy"
label="Proxy Events"
icon={<ReverseProxyIcon size={15} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Proxy Events</h1>
<Paragraph>
View access logs for your reverse proxy services, including allowed
and denied requests.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink href={REVERSE_PROXY_EVENTS_DOCS_LINK} target="_blank">
Proxy Events <ExternalLinkIcon size={12} />
</InlineLink>{" "}
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page="Proxy Events"
hasAccess={permission?.services?.read}
>
<ServerPaginationProvider
url="/events/proxy"
defaultPageSize={10}
defaultFilters={defaultFilters}
>
<ReverseProxyEventsTable headingTarget={portalTarget} />
</ServerPaginationProvider>
</RestrictedAccess>
</PageContainer>
);
}

View File

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

View File

@@ -0,0 +1,340 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import FullTooltip from "@components/FullTooltip";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import { PageNotFound } from "@components/ui/PageNotFound";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn, singularize } from "@utils/helpers";
import { FolderGit2Icon, Layers3Icon, PencilIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import TeamIcon from "@/assets/icons/TeamIcon";
import { GroupProvider, useGroupContext } from "@/contexts/GroupProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group";
import PageContainer from "@/layouts/PageContainer";
import { GroupDNSZonesSection } from "@/modules/groups/details/GroupDNSZonesSection";
import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection";
import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection";
import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection";
import { GroupPoliciesSection } from "@/modules/groups/details/GroupPoliciesSection";
import { GroupResourcesSection } from "@/modules/groups/details/GroupResourcesSection";
import { GroupSetupKeysSection } from "@/modules/groups/details/GroupSetupKeysSection";
import { GroupUsersSection } from "@/modules/groups/details/GroupUsersSection";
import useGroupDetails from "@/modules/groups/details/useGroupDetails";
export default function GroupPage() {
const queryParameter = useSearchParams();
const { isRestricted } = usePermissions();
const groupId = queryParameter.get("id");
const {
data: group,
isLoading,
error,
} = useFetchApi<Group>(`/groups/${groupId}`, true);
useRedirect("/groups", false, !groupId || isRestricted);
if (isRestricted) {
return (
<PageContainer>
<RestrictedAccess page={"Group Information"} />
</PageContainer>
);
}
if (error)
return (
<PageNotFound
title={error?.message}
description={
"The group you are attempting to access cannot be found. It may have been deleted, or you may not have permission to view it. Please verify the URL or return to the dashboard."
}
/>
);
return group && !isLoading ? (
<PageContainer>
<RoutesProvider>
<GroupProvider group={group} isDetailPage={true}>
<div className={"p-default py-6 pb-0 w-full mb-[6px]"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/groups"}
label={"Groups"}
icon={<FolderGit2Icon size={14} />}
/>
<Breadcrumbs.Item label={group.name} active />
</Breadcrumbs>
<GroupDetailsName />
</div>
<GroupOverviewTabs group={group} />
</GroupProvider>
</RoutesProvider>
</PageContainer>
) : (
<FullScreenLoading />
);
}
const GroupDetailsName = () => {
const { group, isJWTGroup, isAllowedToRename, openGroupRenameModal } =
useGroupContext();
const { permission } = usePermissions();
return (
<div className={"w-full"}>
<h1 className={"flex items-center gap-3 w-full whitespace-nowrap"}>
<GroupBadgeIcon id={group?.id} issued={group?.issued} size={20} />
{group.name}
{group.name !== "All" && permission?.groups?.update && (
<div>
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
{isJWTGroup
? GROUP_TOOLTIP_TEXT.RENAME.JWT
: GROUP_TOOLTIP_TEXT.RENAME.INTEGRATION}
</div>
}
interactive={false}
disabled={isAllowedToRename}
className={"w-full block"}
>
<div
className={cn(
"flex h-8 w-8 items-center justify-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer",
!isAllowedToRename &&
"opacity-40 cursor-not-allowed pointer-events-none",
)}
onClick={openGroupRenameModal}
>
<PencilIcon size={16} />
</div>
</FullTooltip>
</div>
)}
</h1>
</div>
);
};
const validAllGroupTabs = [
"policies",
"resources",
"network-routes",
"nameservers",
"zones",
];
const validOtherGroupTabs = ["users", "peers", "setup-keys"];
const GroupOverviewTabs = ({ group }: { group: Group }) => {
const searchParams = useSearchParams();
const getInitialTab = () => {
const isAllGroup = group.name === "All";
const tabParam = searchParams.get("tab");
const validTabs = isAllGroup
? validAllGroupTabs
: [...validAllGroupTabs, ...validOtherGroupTabs];
if (tabParam === null) return isAllGroup ? "policies" : "users";
if (isAllGroup) {
return validTabs.includes(tabParam) ? tabParam : "policies";
}
return validTabs.includes(tabParam) ? tabParam : "users";
};
const [tab, setTab] = useState(getInitialTab());
const { groupDetails, isLoading } = useGroupDetails(group?.id || "");
const peersCount = groupDetails?.peers_count || 0;
const usersCount = groupDetails?.users?.length || 0;
const policiesCount = groupDetails?.policies?.length || 0;
const resourcesCount = groupDetails?.resources_count || 0;
const routesCount = groupDetails?.routes?.length || 0;
const nameserversCount = groupDetails?.nameservers?.length || 0;
const zonesCount = groupDetails?.zones?.length || 0;
const setupKeysCount = groupDetails?.setupKeys?.length || 0;
return (
<Tabs
defaultValue={tab}
onValueChange={(v) => setTab(v)}
value={tab}
className={"pt-2 pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
{group.name !== "All" && (
<TabsTrigger
value={"users"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<TeamIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Users", usersCount)}
</TabsTrigger>
)}
{group.name !== "All" && (
<TabsTrigger
value={"peers"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<PeerIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Peers", peersCount)}
</TabsTrigger>
)}
<TabsTrigger
value={"policies"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<AccessControlIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Policies", policiesCount)}
</TabsTrigger>
<TabsTrigger
value={"resources"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<Layers3Icon size={14} />
{singularize("Resources", resourcesCount)}
</TabsTrigger>
<TabsTrigger
value={"network-routes"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<NetworkRoutesIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Network Routes", routesCount)}
</TabsTrigger>
<TabsTrigger
value={"nameservers"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<DNSIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Nameservers", nameserversCount)}
</TabsTrigger>
<TabsTrigger
value={"zones"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<DNSZoneIcon
size={16}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Zones", zonesCount)}
</TabsTrigger>
{group.name !== "All" && (
<TabsTrigger
value={"setup-keys"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<SetupKeysIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Setup Keys", setupKeysCount)}
</TabsTrigger>
)}
</TabsList>
<TabsContent value={"users"} className={"pb-8"}>
<GroupUsersSection users={groupDetails?.users} isLoading={isLoading} />
</TabsContent>
<TabsContent value={"peers"} className={"pb-8"}>
<GroupPeersSection
peers={groupDetails?.peersOfGroup}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"policies"} className={"pb-8"}>
<GroupPoliciesSection
policies={groupDetails?.policies}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"resources"} className={"pb-8"}>
<GroupResourcesSection
resources={groupDetails?.networkResources}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"network-routes"} className={"pb-8"}>
<GroupNetworkRoutesSection
routes={groupDetails?.routes}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"nameservers"} className={"pb-8"}>
<GroupNameserversSection
nameserverGroups={groupDetails?.nameservers}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"zones"} className={"pb-8"}>
<GroupDNSZonesSection
zones={groupDetails?.zones}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"setup-keys"} className={"pb-8"}>
<GroupSetupKeysSection
setupKeys={groupDetails?.setupKeys}
isLoading={isLoading}
/>
</TabsContent>
</Tabs>
);
};

View File

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

View File

@@ -0,0 +1,56 @@
"use client";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon, FolderGit2Icon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import Breadcrumbs from "@/components/Breadcrumbs";
import InlineLink from "@/components/InlineLink";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PageContainer from "@/layouts/PageContainer";
const GroupsTable = lazy(() => import("@/modules/groups/table/GroupsTable"));
export default function GroupsPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/groups"}
label={"Groups"}
icon={<FolderGit2Icon size={14} />}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>Groups</h1>
<Paragraph>
Here is the overview of the groups of your organization. You can
delete the unused ones.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
>
Groups
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess hasAccess={permission.groups.read} page={"Groups"}>
<Suspense fallback={<SkeletonTable />}>
<GroupsTable headingTarget={portalTarget} />
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -7,7 +7,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import { ArrowUpRightIcon, ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeersProvider from "@/contexts/PeersProvider";
@@ -16,6 +16,7 @@ import RoutesProvider from "@/contexts/RoutesProvider";
import { Route } from "@/interfaces/Route";
import PageContainer from "@/layouts/PageContainer";
import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
import { Callout } from "@components/Callout";
const NetworkRoutesTable = lazy(
() => import("@/modules/route-group/NetworkRoutesTable"),
@@ -59,6 +60,17 @@ export default function NetworkRoutes() {
</InlineLink>
in our documentation.
</Paragraph>
<Callout className={"max-w-xl mt-5"} variant={"warning"}>
<span>
We recommend using the new Networks concept to easier visualise
and manage access to your resources.{" "}
<InlineLink href={"/networks"}>
Go to Networks
<ArrowUpRightIcon size={14} />
</InlineLink>
</span>
</Callout>
</div>
<RestrictedAccess hasAccess={permission.routes.read}>

View File

@@ -1,34 +1,54 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import Card from "@components/Card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { cn, singularize } from "@utils/helpers";
import {
ArrowUpRightIcon,
HelpCircle,
Layers3Icon,
MoreVertical,
PencilLineIcon,
ServerIcon,
ShieldCheckIcon,
ShieldXIcon,
Trash2,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useMemo } from "react";
import useUrlTab from "@/hooks/useUrlTab";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network } from "@/interfaces/Network";
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
import NetworkModal from "@/modules/networks/NetworkModal";
import { NetworkProvider } from "@/modules/networks/NetworkProvider";
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
import {
NetworkProvider,
useNetworksContext,
} from "@/modules/networks/NetworkProvider";
import { ResourcesTabContent } from "@/modules/networks/resources/ResourcesTabContent";
import { NetworkRoutingPeersTabContent } from "@/modules/networks/routing-peers/NetworkRoutingPeersTabContent";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import PeerIcon from "@/assets/icons/PeerIcon";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import ReverseProxiesProvider, {
flattenReverseProxies,
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
export default function NetworkDetailPage() {
const queryParameter = useSearchParams();
@@ -41,7 +61,9 @@ export default function NetworkDetailPage() {
useRedirect("/networks", false, !networkId);
return network && !isLoading ? (
<NetworkOverview network={network} />
<ReverseProxiesProvider initialNetwork={network}>
<NetworkOverview network={network} />
</ReverseProxiesProvider>
) : (
<FullScreenLoading />
);
@@ -50,8 +72,23 @@ export default function NetworkDetailPage() {
function NetworkOverview({ network }: Readonly<{ network: Network }>) {
const { permission } = usePermissions();
const [networkModal, setNetworkModal] = useState(false);
const { mutate } = useSWRConfig();
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
>(`/networks/${network.id}/resources`);
const { data: routers, isLoading: isRoutersLoading } = useFetchApi<
NetworkRouter[]
>(`/networks/${network.id}/routers`);
const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies();
const services = useMemo(
() => flattenReverseProxies({ reverseProxies, network }),
[reverseProxies, network],
);
const [tab, setTab] = useUrlTab(
["resources", "routing-peers", "services"],
"resources",
);
const isActive = !!(
network?.routing_peers_count && network.routing_peers_count > 0
@@ -60,7 +97,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
return (
<PageContainer>
<NetworkProvider network={network}>
<div className={"p-default py-6 mb-4"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
@@ -77,35 +114,24 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
<div className={"flex justify-between max-w-6xl"}>
<div
className={cn(
"flex items-center",
!network.description && "gap-2",
)}
className={"w-full lg:w-1/2 flex justify-between items-center"}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
{permission.networks.update && (
<button
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"
}
onClick={() => setNetworkModal(true)}
>
<PencilLineIcon size={18} />
</button>
)}
<NetworkModal
open={networkModal}
setOpen={setNetworkModal}
onUpdated={() => {
mutate(`/networks/${network.id}`);
}}
network={network}
/>
<div
className={cn(
"flex items-center w-full",
!network.description && "gap-2",
)}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
</div>
<NetworkProvider network={network}>
<NetworkActions />
</NetworkProvider>
</div>
</div>
@@ -114,16 +140,113 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
</div>
</div>
<Separator />
<ResourcesSection network={network} />
<div className={"h-3"} />
<Separator />
<NetworkRoutingPeersSection network={network} />
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"resources"}>
<Layers3Icon size={14} />
{singularize("Resources", network?.resources?.length)}
</TabsTrigger>
<TabsTrigger value={"routing-peers"}>
<PeerIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Routing Peers", network?.routing_peers_count)}
</TabsTrigger>
<TabsTrigger value={"services"}>
<ReverseProxyIcon
size={16}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Services", services.length)}
</TabsTrigger>
</TabsList>
<TabsContent value={"resources"} className={"pb-8"}>
<ResourcesTabContent
data={resources}
isLoading={isResourcesLoading}
/>
</TabsContent>
<TabsContent value={"routing-peers"} className={"pb-8"}>
<NetworkRoutingPeersTabContent
routers={routers}
isLoading={isRoutersLoading}
/>
</TabsContent>
<TabsContent value={"services"} className={"pb-8"}>
<ReverseProxyFlatTargetsTabContent
targets={services}
isLoading={isServicesLoading}
/>
</TabsContent>
</Tabs>
</NetworkProvider>
</PageContainer>
);
}
function NetworkActions() {
const { permission } = usePermissions();
const { deleteNetwork, openEditNetworkModal, network } = useNetworksContext();
const router = useRouter();
if (!network) return;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
asChild={true}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Button variant={"secondary"} className={"!px-3"}>
<MoreVertical size={16} className={"shrink-0"} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end">
<DropdownMenuItem
onClick={() => openEditNetworkModal(network)}
disabled={!permission.networks.update}
>
<div className={"flex gap-3 items-center"}>
<PencilLineIcon size={14} className={"shrink-0"} />
Rename
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
deleteNetwork(network).then(() => router.push("/networks"))
}
variant={"danger"}
disabled={!permission.networks.delete}
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const isHighlyAvailable = !!(
network?.routing_peers_count && network?.routing_peers_count >= 2
@@ -154,7 +277,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const policyCount = network.policies?.length ?? 0;
return (
<Card>
<Card className={"w-full lg:w-1/2"}>
<Card.List>
<Card.ListItem
tooltip={false}

View File

@@ -4,8 +4,6 @@ import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import FullTooltip from "@components/FullTooltip";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
@@ -28,24 +26,25 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { singularize } from "@utils/helpers";
import dayjs from "dayjs";
import { isEmpty, trim } from "lodash";
import {
ArrowRightIcon,
Barcode,
CalendarDays,
Cpu,
FlagIcon,
Globe,
History,
LockIcon,
ListIcon,
MapPin,
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
TerminalSquare,
TimerResetIcon,
RadioTowerIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { toASCII } from "punycode";
import React, { useMemo, useState } from "react";
@@ -55,19 +54,28 @@ 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 ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { useCountries } from "@/contexts/CountryProvider";
import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { useHasChanges } from "@/hooks/useHasChanges";
import type { Group } from "@/interfaces/Group";
import type { Peer } from "@/interfaces/Peer";
import PageContainer from "@/layouts/PageContainer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection";
import ReverseProxiesProvider, {
flattenReverseProxies,
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
export default function PeerPage() {
const queryParameter = useSearchParams();
@@ -81,13 +89,6 @@ export default function PeerPage() {
useRedirect("/peers", false, !peerId || isRestricted);
const peerKey = useMemo(() => {
let id = peer?.id ?? "";
let ssh = peer?.ssh_enabled ? "1" : "0";
let expiration = peer?.login_expiration_enabled ? "1" : "0";
return `${id}-${ssh}-${expiration}`;
}, [peer]);
if (isRestricted) {
return (
<PageContainer>
@@ -106,10 +107,12 @@ export default function PeerPage() {
/>
);
return peer && !isLoading ? (
<PeerProvider peer={peer} key={peerId}>
<PeerOverview key={peerKey} />
</PeerProvider>
return peer && peer.id && !isLoading ? (
<ReverseProxiesProvider initialPeer={peer}>
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
<PeerOverview key={peer?.id} />
</PeerProvider>
</ReverseProxiesProvider>
) : (
<FullScreenLoading />
);
@@ -121,50 +124,62 @@ function PeerOverview() {
return (
<PageContainer>
<RoutesProvider>
<div className={"p-default py-6 pb-0"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/peers"}
label={"Peers"}
icon={<PeerIcon size={13} />}
/>
<Breadcrumbs.Item label={peer.ip} active />
</Breadcrumbs>
<PeerGeneralInformation />
</div>
<PeerOverviewTabs />
<PeerSettingsProvider>
<div className={"p-default py-6 pb-0"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/peers"}
label={"Peers"}
icon={<PeerIcon size={13} />}
/>
<Breadcrumbs.Item label={peer.ip} active />
</Breadcrumbs>
<PeerHeader />
</div>
<PeerOverviewTabs />
</PeerSettingsProvider>
</RoutesProvider>
</PageContainer>
);
}
const PeerGeneralInformation = () => {
const router = useRouter();
type PeerSettingsContextType = {
selectedGroups: Group[];
setSelectedGroups: React.Dispatch<React.SetStateAction<Group[]>>;
hasChanges: boolean;
updatePeer: (newName?: string) => Promise<void>;
name: string;
setName: (name: string) => void;
tab: string;
setTab: (tab: string) => void;
};
const PeerSettingsContext = React.createContext<PeerSettingsContextType | null>(
null,
);
const usePeerSettings = () => {
const context = React.useContext(PeerSettingsContext);
if (!context) {
throw new Error("usePeerSettings must be used within PeerSettingsProvider");
}
return context;
};
const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => {
const { mutate } = useSWRConfig();
const { peer, user, peerGroups, openSSHDialog, update } = usePeer();
const [ssh, setSsh] = useState(peer.ssh_enabled);
const { peer, peerGroups, update } = usePeer();
const { permission } = usePermissions();
const [name, setName] = useState(peer.name);
const [showEditNameModal, setShowEditNameModal] = useState(false);
const [loginExpiration, setLoginExpiration] = useState(
peer.login_expiration_enabled,
);
const [inactivityExpiration, setInactivityExpiration] = useState(
peer.inactivity_expiration_enabled,
);
const [tab, setTab] = useState("overview");
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({
initial: peerGroups,
initial: peerGroups?.filter((g) => g?.name !== "All"),
peer,
});
/**
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
*/
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
]);
const updatePeer = async (newName?: string) => {
@@ -174,9 +189,6 @@ const PeerGeneralInformation = () => {
if (permission.peers.update) {
const updateRequest = update({
name: newName ?? name,
ssh,
loginExpiration,
inactivityExpiration,
});
batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest];
} else {
@@ -189,18 +201,37 @@ const PeerGeneralInformation = () => {
promise: Promise.all(batchCall).then(() => {
mutate("/peers/" + peer.id);
mutate("/groups");
updateHasChangedRef([
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
]);
updateHasChangedRef([selectedGroups]);
}),
loadingMessage: "Saving the peer...",
});
};
return (
<PeerSettingsContext.Provider
value={{
selectedGroups,
setSelectedGroups,
hasChanges,
updatePeer,
name,
setName,
tab,
setTab,
}}
>
{children}
</PeerSettingsContext.Provider>
);
};
const PeerHeader = () => {
const router = useRouter();
const { peer, user } = usePeer();
const { permission } = usePermissions();
const { name, setName, hasChanges, updatePeer, tab } = usePeerSettings();
const [showEditNameModal, setShowEditNameModal] = useState(false);
const isOverviewTab = tab === "overview";
return (
<>
@@ -245,121 +276,161 @@ const PeerGeneralInformation = () => {
</h1>
<LoginExpiredBadge loginExpired={peer.login_expired} />
</div>
<div className={"flex items-center gap-8"}>
<Paragraph className={"flex items-center"}>{user?.email}</Paragraph>
{(user?.id || user?.email) && (
<div className={"flex items-center gap-8"}>
<Paragraph className={"flex items-center"}>
<Link
href={`/team/user?id=${user?.id}`}
className={
"hover:text-nb-gray-200 transition-all flex items-center gap-1"
}
>
{user?.email || user?.id}
<ArrowRightIcon size={14} />
</Link>
</Paragraph>
</div>
)}
</div>
{isOverviewTab && (
<div className={"flex gap-4"}>
<Button
variant={"default"}
className={"w-full"}
onClick={() => router.push("/peers")}
>
Cancel
</Button>
<Button
variant={"primary"}
className={"w-full"}
onClick={() => updatePeer()}
disabled={
!hasChanges ||
!permission.peers.update ||
!permission.groups.update
}
>
Save Changes
</Button>
</div>
</div>
<div className={"flex gap-4"}>
<Button
variant={"default"}
className={"w-full"}
onClick={() => router.push("/peers")}
>
Cancel
</Button>
<Button
variant={"primary"}
className={"w-full"}
onClick={() => updatePeer()}
disabled={
!hasChanges || !permission.peers.read || !permission.groups.update
}
>
Save Changes
</Button>
</div>
)}
</div>
</>
);
};
const PeerOverviewTabs = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies();
const { tab, setTab } = usePeerSettings();
const flatTargets = useMemo(
() => flattenReverseProxies({ reverseProxies, peer }),
[reverseProxies, peer],
);
return (
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pt-4 pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"overview"}>
<ListIcon size={16} />
Overview
</TabsTrigger>
{permission.routes.read && (
<TabsTrigger value={"network-routes"}>
<NetworkIcon size={16} />
Network Routes
</TabsTrigger>
)}
{peer?.id && permission.peers.read && (
<TabsTrigger value={"accessible-peers"}>
<MonitorSmartphoneIcon size={16} />
Accessible Peers
</TabsTrigger>
)}
{peer?.id && permission.services?.read && (
<TabsTrigger value={"reverse-proxies"}>
<ReverseProxyIcon
size={16}
className="fill-nb-gray-400 group-data-[state=active]/trigger:fill-netbird"
/>
{singularize("Services", flatTargets.length)}
</TabsTrigger>
)}
{peer?.id && permission.peers.delete && (
<TabsTrigger value={"peer-job"}>
<RadioTowerIcon size={16} />
Remote Jobs
</TabsTrigger>
)}
</TabsList>
<TabsContent value={"overview"} className={"pb-8"}>
<PeerOverviewTabContent />
</TabsContent>
{permission.routes.read && (
<TabsContent value={"network-routes"} className={"pb-8"}>
<PeerNetworkRoutesSection peer={peer} />
</TabsContent>
)}
{peer?.id && permission.peers.read && (
<TabsContent value={"accessible-peers"} className={"pb-8"}>
<AccessiblePeersSection peerID={peer.id} />
</TabsContent>
)}
{peer?.id && permission.services?.read && (
<TabsContent value={"reverse-proxies"} className={"pb-8"}>
<ReverseProxyFlatTargetsTabContent
targets={flatTargets}
isLoading={isServicesLoading}
hideResourceColumn
emptyTableTitle={"This peer has no services"}
emptyTableDescription={
"Add your services to this peer and securely expose them through NetBird's reverse proxy"
}
/>
</TabsContent>
)}
{peer.id && permission.peers.delete && (
<TabsContent value={"peer-job"} className={"pb-8"}>
<PeerRemoteJobsSection peerID={peer.id} />
</TabsContent>
)}
</Tabs>
);
};
const PeerOverviewTabContent = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const { selectedGroups, setSelectedGroups } = usePeerSettings();
return (
<div className={"px-8"}>
<div
className={
"flex-wrap xl:flex-nowrap flex gap-10 w-full mt-5 max-w-6xl items-start"
"flex-wrap xl:flex-nowrap flex gap-10 w-full items-start pt-2 max-w-6xl"
}
>
<PeerInformationCard peer={peer} />
<div className={"flex flex-col gap-6 lg:w-1/2 transition-all"}>
<div>
<PeerExpirationToggle
peer={peer}
value={loginExpiration}
icon={<TimerResetIcon size={16} />}
onChange={(state) => {
setLoginExpiration(state);
!state && setInactivityExpiration(false);
}}
/>
{permission.peers.update && !!peer?.user_id && (
<div
className={cn(
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
!loginExpiration
? "opacity-50 pointer-events-none"
: "bg-nb-gray-930/80",
)}
>
<PeerExpirationToggle
peer={peer}
variant={"blank"}
value={inactivityExpiration}
onChange={setInactivityExpiration}
title={"Require login after disconnect"}
description={
"Enable to require authentication after users disconnect from management for 10 minutes."
}
className={
!loginExpiration ? "opacity-40 pointer-events-none" : ""
}
/>
</div>
)}
</div>
<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={permission.peers.update}
>
<FancyToggleSwitch
value={ssh}
disabled={!permission.peers.update}
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>
{/* Remote Access Buttons */}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
</div>
<div className={"flex flex-col gap-8 lg:w-1/2 transition-all"}>
<PeerExpirationSettings />
{permission.groups.read && (
<div>
<Label>Assigned Groups</Label>
@@ -375,55 +446,21 @@ const PeerGeneralInformation = () => {
/>
</div>
)}
<PeerSSHToggle />
{/* Remote Access Buttons */}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
</div>
</div>
</div>
</>
);
};
const PeerOverviewTabs = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const [tab, setTab] = useState(
permission.routes.read ? "network-routes" : "accessible-peers",
);
return (
<Tabs
defaultValue={tab}
onValueChange={(v) => setTab(v)}
value={tab}
className={"pt-10 pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
{permission.routes.read && (
<TabsTrigger value={"network-routes"}>
<NetworkIcon size={16} />
Network Routes
</TabsTrigger>
)}
{peer?.id && permission.peers.read && (
<TabsTrigger value={"accessible-peers"}>
<MonitorSmartphoneIcon size={16} />
Accessible Peers
</TabsTrigger>
)}
</TabsList>
{permission.routes.read && (
<TabsContent value={"network-routes"} className={"pb-8"}>
<PeerNetworkRoutesSection peer={peer} />
</TabsContent>
)}
{peer?.id && permission.peers.read && (
<TabsContent value={"accessible-peers"} className={"pb-8"}>
<AccessiblePeersSection peerID={peer.id} />
</TabsContent>
)}
</Tabs>
</div>
);
};

View File

@@ -105,7 +105,7 @@ function PeersBlockedView() {
<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"
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
}
>
<SetupModalContent header={false} footer={false} />

View File

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

View File

@@ -0,0 +1,70 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
import { REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
const CustomDomainsTable = lazy(
() => import("@/modules/reverse-proxy/domain/CustomDomainsTable"),
);
export default function ReverseProxyCustomDomainsPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Reverse Proxy"}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/custom-domains"}
label={"Custom Domains"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Domains</h1>
<Paragraph>
Add and manage custom domains for your reverse proxy services.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK}
target={"_blank"}
>
Custom Domains
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Custom Domains"}
hasAccess={permission?.services?.read}
>
<ReverseProxiesProvider>
<Suspense fallback={<SkeletonTable />}>
<CustomDomainsTable headingTarget={portalTarget} />
</Suspense>
</ReverseProxiesProvider>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function ReverseProxyRedirectPage() {
const router = useRouter();
useEffect(() => {
router.replace("/reverse-proxy/services");
}, [router]);
return <FullScreenLoading fullScreen={false} />;
}

View File

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

View File

@@ -0,0 +1,83 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
import { REVERSE_PROXY_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
import { Callout } from "@components/Callout";
import { isNetBirdHosted } from "@utils/netbird";
const ReverseProxyTable = lazy(
() => import("@/modules/reverse-proxy/table/ReverseProxyTable"),
);
export default function ReverseProxyServicesPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Reverse Proxy"}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Services"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Services</h1>
<Paragraph>
Expose services securely through NetBird&apos;s reverse proxy.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={REVERSE_PROXY_DOCS_LINK} target={"_blank"}>
Services
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
{isNetBirdHosted() ? (
<Callout className={"max-w-xl mt-5"} variant={"info"}>
NetBird&apos;s Reverse Proxy is currently in beta and available at
no cost during this period. Features, functionality, and pricing are
subject to change upon release.
</Callout>
) : (
<Callout className={"max-w-xl mt-5"} variant={"info"}>
NetBird&apos;s Reverse Proxy is currently in beta. <br /> Features
and functionality are subject to change upon release.
</Callout>
)}
</div>
<RestrictedAccess
page={"Services"}
hasAccess={permission?.services?.read}
>
<ReverseProxiesProvider>
<Suspense fallback={<SkeletonTable />}>
<ReverseProxyTable headingTarget={portalTarget} />
</Suspense>
</ReverseProxiesProvider>
</RestrictedAccess>
</PageContainer>
);
}

View File

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

View File

@@ -11,5 +11,5 @@ export default function Team() {
router.push("/team/users");
}, [router]);
return <FullScreenLoading height={"auto"} />;
return <FullScreenLoading fullScreen={false} />;
}

View File

@@ -9,6 +9,7 @@ import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import Separator from "@components/Separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useRedirect from "@hooks/useRedirect";
@@ -16,7 +17,15 @@ import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react";
import useFetchApi, { useApiCall } from "@utils/api";
import { generateColorFromString } from "@utils/helpers";
import dayjs from "dayjs";
import { Ban, GalleryHorizontalEnd, History, Mail, User2 } from "lucide-react";
import {
Ban,
GalleryHorizontalEnd,
History,
KeyRoundIcon,
Mail,
MonitorSmartphoneIcon,
User2,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
@@ -33,6 +42,7 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
import UserBlockCell from "@/modules/users/table-cells/UserBlockCell";
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
import { UserPeersSection } from "@/modules/users/UserPeersSection";
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
export default function UserPage() {
@@ -80,6 +90,7 @@ type Props = {
function UserOverview({ user, initialGroups }: Readonly<Props>) {
const router = useRouter();
const userRequest = useApiCall<User>("/users");
const isServiceUser = !!user?.is_service_user;
const { mutate } = useSWRConfig();
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
@@ -91,7 +102,6 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
});
const [role, setRole] = useState(user.role || Role.User);
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
role,
selectedGroups,
@@ -114,13 +124,24 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
`/${user.id}`,
)
.then(() => {
mutate(`/users?service_user=${user.is_service_user}`);
mutate(`/users?service_user=${isServiceUser}`);
updateChangesRef([role, selectedGroups]);
}),
loadingMessage: "Saving changes...",
});
};
const isProfilePage = !!user?.is_current && !isServiceUser;
const canViewTokens = permission?.pats?.read;
const canViewPeers = permission?.peers?.read;
const showAccessTokens = (user?.is_current || isServiceUser) && canViewTokens;
const showPeers = !isServiceUser && canViewPeers;
const showTabs = isProfilePage && showPeers && showAccessTokens;
const showSeparator = !showTabs;
const [tab, setTab] = useState(isServiceUser ? "access-tokens" : "peers");
return (
<PageContainer>
<div className={"p-default py-6 mb-4"}>
@@ -132,7 +153,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
icon={<TeamIcon size={13} />}
/>
{user.is_service_user ? (
{isServiceUser ? (
<Breadcrumbs.Item
href={"/team/service-users"}
label={"Service Users"}
@@ -158,7 +179,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
}
style={
user.is_service_user
isServiceUser
? {
color: "white",
}
@@ -171,13 +192,13 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
}
}
>
{user.is_service_user ? (
{isServiceUser ? (
<IconSettings2 size={16} />
) : (
user?.name?.charAt(0) || user?.id?.charAt(0)
)}
</div>
<h1 className={"flex items-center gap-3"}>
<h1 className={"flex items-center gap-3"} title={user?.id}>
{user.name || user.id}
</h1>
</div>
@@ -188,7 +209,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
variant={"default"}
className={"w-full"}
onClick={() => {
user.is_service_user
isServiceUser
? router.push("/team/service-users")
: router.push("/team/users");
}}
@@ -212,7 +233,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<UserInformationCard user={user} />
<div className={"flex flex-col gap-8 w-1/2 "}>
{!user.is_service_user && isOwnerOrAdmin && (
{!isServiceUser && isOwnerOrAdmin && (
<div>
<Label>Auto-assigned groups</Label>
<HelpText>
@@ -238,7 +259,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
<UserRoleSelector
value={role}
onChange={setRole}
hideOwner={user.is_service_user}
hideOwner={isServiceUser}
currentUser={user}
disabled={isLoggedInUser || !permission.users.update}
/>
@@ -248,38 +269,65 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
</div>
</div>
{(user.is_current || user.is_service_user) && permission.pats.read && (
<>
<Separator />
<div className={"px-8 py-6"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
<div>
<h2>Access Tokens</h2>
<Paragraph>
Access tokens give access to NetBird API.
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
{showSeparator && <Separator />}
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"} hidden={!showTabs}>
{showPeers && (
<TabsTrigger value={"peers"}>
<MonitorSmartphoneIcon size={16} />
Peers
</TabsTrigger>
)}
{showAccessTokens && (
<TabsTrigger value={"access-tokens"}>
<KeyRoundIcon size={16} />
Access Tokens
</TabsTrigger>
)}
</TabsList>
{showPeers && (
<TabsContent value={"peers"} className={"pb-8"}>
<UserPeersSection user={user} />
</TabsContent>
)}
{showAccessTokens && (
<TabsContent value={"access-tokens"} className={"pb-8"}>
<div className={"px-8"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
<div>
<CreateAccessTokenModal user={user}>
<Button
variant={"primary"}
data-cy={"access-token-open-modal"}
disabled={!permission.pats.create}
>
<IconCirclePlus size={16} />
Create Access Token
</Button>
</CreateAccessTokenModal>
<h2>Access Tokens</h2>
<Paragraph>
Access tokens give access to NetBird API.
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div>
<CreateAccessTokenModal user={user}>
<Button
variant={"primary"}
data-cy={"access-token-open-modal"}
disabled={!permission.pats.create}
>
<IconCirclePlus size={16} />
Create Access Token
</Button>
</CreateAccessTokenModal>
</div>
</div>
</div>
<AccessTokensTable user={user} />
</div>
<AccessTokensTable user={user} />
</div>
</div>
</>
)}
</TabsContent>
)}
</Tabs>
</PageContainer>
);
}

View File

@@ -31,7 +31,7 @@ export default function RDPPage() {
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
return (
<div className={"w-screen h-screen overflow-hidden"}>
<div className={"w-screen h-screen overflow-hidden fixed inset-0"}>
{peerId && peer && !isLoading ? (
<RDPSession key={peer.id} peer={peer} />
) : (
@@ -55,7 +55,7 @@ function RDPSession({ peer }: Props) {
useEffect(() => {
document.title = `${peer.name} - ${peer.ip} - RDP`;
}, []);
}, [peer.ip, peer.name, connected, rdp]);
const sendErrorNotification = (title: string, message: string) => {
notify({
@@ -84,7 +84,9 @@ function RDPSession({ peer }: Props) {
try {
setCredentials(rdpCredentials);
setIsNetBirdConnecting(true);
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
await client.connectTemporary(peer.id, [
`tcp/${rdpCredentials.port}`,
]);
setIsNetBirdConnecting(false);
} catch (error) {
sendErrorNotification(
@@ -104,6 +106,7 @@ function RDPSession({ peer }: Props) {
port: credentials.port,
username: credentials.username,
password: credentials.password,
domain: credentials.domain,
width: window.innerWidth,
height: window.innerHeight,
});

View File

@@ -12,6 +12,10 @@ import {
NetBirdStatus,
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import {
isNativeSSHSupported,
isNetbirdSSHProtocolSupported,
} from "@utils/version";
export default function SSHPage() {
const { peerId, username, port } = useSSHQueryParams();
@@ -86,7 +90,11 @@ function SSHTerminal({ username, port, peer }: Props) {
if (isSSHConnected || isSSHConnecting) return;
connected.current = false;
try {
const rules = [`tcp/${port}`];
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: peer.ip,
@@ -106,8 +114,13 @@ function SSHTerminal({ username, port, peer }: Props) {
if (!peer.id) return;
if (connected.current) return;
connected.current = true;
try {
const rules = [`tcp/${port}`];
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
const res = await ssh({
hostname: peer.ip,
@@ -118,7 +131,7 @@ function SSHTerminal({ username, port, peer }: Props) {
sshConnectedOnce.current = true;
}
} catch (error) {
console.error("Connection failed:", error);
console.error("Connection error:", error);
}
};

View File

@@ -2,7 +2,14 @@
@tailwind components;
@tailwind utilities;
:root {
--toasts-before: 0;
--lift: 1;
}
html{
@apply bg-nb-gray;
}
h1 {
@apply text-2xl font-medium text-gray-700 dark:text-nb-gray-100 my-1;
@@ -167,4 +174,29 @@ p {
.xterm-viewport {
@apply m-0 p-0 box-border;
}
}
/* Disable sonner's opacity fade-in for custom toasts, but respect visibility */
[data-sonner-toast][data-visible="true"] {
opacity: 1 !important;
}
/* Adjust sonner stacking: less shrink and less lift per toast */
[data-sonner-toast][data-expanded="false"][data-front="false"] {
--scale: calc(var(--toasts-before) * 0.03 - 1) !important;
--lift-amount: calc(var(--lift) * 10px) !important;
}
/* Override stacked toast removal to move up instead of down */
[data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false'] {
--y: translateY(calc(var(--lift) * -20%)) !important;
opacity: 0 !important;
transition: transform 400ms ease, opacity 300ms ease !important;
}
/* Control Center */
.react-flow__node-groupNode .selected{
@apply border-netbird;
}

View File

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

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

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

View File

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

View File

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

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

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

22
src/app/setup/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
"use client";
import InstanceSetupWizard from "@/modules/instance-setup/InstanceSetupWizard";
import { useInstanceSetup } from "@/contexts/InstanceSetupProvider";
import { useRouter } from "next/navigation";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useEffect } from "react";
export default function SetupPage() {
const { setupRequired, loading } = useInstanceSetup();
const router = useRouter();
useEffect(() => {
if (!loading && !setupRequired) router.replace("/peers");
}, [loading, setupRequired]);
return loading || !setupRequired ? (
<FullScreenLoading />
) : (
<InstanceSetupWizard />
);
}

View File

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

View File

@@ -0,0 +1,22 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function ControlCenterIcon(props: IconProps) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
{...iconProperties(props)}
>
<path d="M5 3a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5Zm0 12a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2H5Zm12 0a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2h-2Zm0-12a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-2Z" />
<path
fillRule="evenodd"
d="M10 6.5a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1ZM10 18a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm-4-4a1 1 0 0 1-1-1v-2a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1Zm12 0a1 1 0 0 1-1-1v-2a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -0,0 +1,19 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function DNSZoneIcon(props: IconProps) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
fillRule="evenodd"
d="M5 5a2 2 0 0 0-2 2v3a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V7a2 2 0 0 0-2-2H5Zm9 2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17ZM3 17v-3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm11-2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

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

View File

@@ -0,0 +1,19 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function JumpcloudIcon(props: Readonly<IconProps>) {
return (
<svg
width="167"
height="82"
viewBox="0 0 167 82"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
d="M166.911 58.3592C166.911 64.3815 164.519 70.1571 160.26 74.4155C156.002 78.6739 150.226 81.0662 144.204 81.0662H137.961C137.31 73.4972 129.5 67.0612 118.46 64.0722C121.244 61.3253 123.148 57.8124 123.931 53.9803C124.713 50.1482 124.338 46.17 122.854 42.5515C121.369 38.933 118.842 35.8378 115.594 33.6594C112.345 31.481 108.522 30.3178 104.611 30.3178C100.7 30.3178 96.8772 31.481 93.6289 33.6594C90.3805 35.8378 87.8534 38.933 86.3689 42.5515C84.8843 46.17 84.5094 50.1482 85.2918 53.9803C86.0743 57.8124 87.9786 61.3253 90.7628 64.0722C85.5111 65.3278 80.6301 67.8055 76.5167 71.3037C73.9207 69.8152 71.1411 68.6726 68.2487 67.9049C70.6422 65.5587 72.2829 62.5529 72.9614 59.2707C73.6399 55.9884 73.3255 52.5784 72.0584 49.4755C70.7913 46.3726 68.6288 43.7174 65.8467 41.8484C63.0646 39.9793 59.7888 38.9812 56.4372 38.9812C53.0855 38.9812 49.8098 39.9793 47.0277 41.8484C44.2455 43.7174 42.0831 46.3726 40.816 49.4755C39.5488 52.5784 39.2345 55.9884 39.913 59.2707C40.5915 62.5529 42.2321 65.5587 44.6257 67.9049C35.9237 70.3154 29.5841 75.1364 28.2342 80.9698H21.991C16.0936 80.7777 10.502 78.2999 6.39821 74.0603C2.2944 69.8206 0 64.1513 0 58.2508C0 52.3503 2.2944 46.681 6.39821 42.4413C10.502 38.2016 16.0936 35.7238 21.991 35.5317C24.8814 35.5419 27.7438 36.0981 30.4278 37.1709C32.2478 33.2162 35.1686 29.8695 38.8407 27.5312C42.5128 25.1928 46.7807 23.9618 51.1341 23.9854C51.6885 23.9854 52.2429 23.9854 52.7732 23.9854C53.9093 18.1059 56.8018 12.7093 61.0689 8.50798C65.336 4.30669 70.7769 1.49837 76.6733 0.453829C82.5698 -0.590709 88.6443 0.177651 94.095 2.65746C99.546 5.13728 104.116 9.21191 107.203 14.3434C110.733 13.2708 114.463 13.023 118.104 13.6193C121.746 14.2155 125.202 15.6397 128.206 17.7822C131.21 19.9247 133.682 22.7283 135.432 25.977C137.182 29.2257 138.162 32.8326 138.298 36.52C141.665 35.6031 145.198 35.4762 148.622 36.1492C152.046 36.8222 155.269 38.277 158.038 40.4001C160.808 42.5233 163.049 45.2574 164.588 48.3892C166.127 51.5211 166.922 54.9661 166.911 58.4557V58.3592Z"
fill="#4CC2BF"
/>
</svg>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function OIDCIcon(props: Readonly<IconProps>) {
return (
<svg
width="173"
height="174"
viewBox="0 0 173 174"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
d="M76.3945 173.48L103.325 154.065L102.072 0L76.3945 20.041V173.48Z"
fill="#FF8E00"
/>
<path
d="M76.7077 173.48C-24.0221 157.466 -26.8926 69.7689 76.0814 50.7288L76.3945 68.8909C3.35034 81.0694 12.6045 146.598 76.3945 156.257L76.7077 173.48Z"
fill="white"
/>
<path
d="M103.011 68.2646C115.468 68.3493 126.32 74.0515 137.144 79.8508L121.174 91.7502H172.216L172.529 56.9916L156.558 68.8909C140.397 60.7278 125.542 50.9315 103.011 50.7288V68.2646Z"
fill="white"
/>
</svg>
);
}

View File

@@ -0,0 +1,26 @@
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { cn } from "@utils/helpers";
import * as React from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { OSLogo } from "@/modules/peers/PeerOSCell";
type Props = {
os: string;
};
export const PeerOSIcon = ({ os }: Props) => {
const osType = getOperatingSystem(os);
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
osType === OperatingSystem.APPLE && "p-[2.7px]",
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={os} />
</div>
);
};

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import { PeerOSIcon } from "./PeerOSIcon";
import { ResourceIcon } from "./ResourceIcon";
type Props = {
peer?: Peer;
resource?: NetworkResource;
};
export const PeerOrResourceIcon = ({ peer, resource }: Props) => {
return (
<>
{peer && <PeerOSIcon os={peer.os} />}
{resource?.type && <ResourceIcon type={resource.type} />}
</>
);
};

View File

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

View File

@@ -0,0 +1,20 @@
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
import * as React from "react";
type Props = {
type: "domain" | "host" | "subnet";
size?: number;
};
export const ResourceIcon = ({ type, size = 15 }: Props) => {
switch (type) {
case "domain":
return <GlobeIcon size={size} />;
case "subnet":
return <NetworkIcon size={size} />;
case "host":
return <WorkflowIcon size={size} />;
default:
return <WorkflowIcon size={size} />;
}
};

View File

@@ -0,0 +1,15 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function ReverseProxyIcon(props: IconProps) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z" />
</svg>
);
}

View File

@@ -0,0 +1,30 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function SlackIcon(props: Readonly<IconProps>) {
return (
<svg
width="127"
height="127"
viewBox="0 0 127 127"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
fill="#E01E5A"
/>
<path
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
fill="#36C5F0"
/>
<path
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
fill="#2EB67D"
/>
<path
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
fill="#ECB22E"
/>
</svg>
);
}

View File

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

View File

@@ -1,12 +0,0 @@
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_4)">
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#686868"/>
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
</g>
<defs>
<clipPath id="clip0_4_4">
<rect width="573" height="148" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -1,12 +0,0 @@
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_4)">
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#359CEF"/>
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
</g>
<defs>
<clipPath id="clip0_4_4">
<rect width="573" height="148" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

@@ -6,10 +6,8 @@ import {
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";
import { usePathname, useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { OIDCError } from "@/auth/OIDCError";
import { SecureProvider } from "@/auth/SecureProvider";
@@ -44,39 +42,11 @@ 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(() => {
const validParams = [
"tab",
"search",
"id",
"invite",
"utm_source",
"utm_medium",
"utm_content",
"utm_campaign",
"hs_id",
"page",
"page_size",
"user",
"port",
];
try {
const urlParams = new URLSearchParams(params);
if (validParams.some((param) => urlParams.has(param))) {
setQueryParams(params);
}
} catch (e) {}
}, []);
const withCustomHistory = () => {
return {
replaceState: (url: any) => {
router.replace(url);
window.dispatchEvent(new Event("popstate"));
window?.location?.replace(url);
},
};
};
@@ -105,16 +75,19 @@ export default function OIDCProvider({ children }: Props) {
// We bypass authentication for pages that do not require auth.
// E.g., when we just want to show installation steps for public.
if (path === "/install") return children;
// Or the instance setup wizard for first-time setup.
// Or the invite acceptance page for new users.
if (path === "/install" || path === "/setup" || path?.startsWith("/invite"))
return children;
return mounted && providerConfig ? (
<OidcProvider
configuration={providerConfig}
//withCustomHistory={withCustomHistory}
withCustomHistory={withCustomHistory}
authenticatingComponent={FullScreenLoading}
authenticatingErrorComponent={OIDCError}
loadingComponent={FullScreenLoading}
callbackSuccessComponent={CallBackSuccess}
callbackSuccessComponent={FullScreenLoading}
onEvent={onEvent}
onSessionLost={() => void 0}
//sessionLostComponent={SessionLost}
@@ -125,11 +98,3 @@ export default function OIDCProvider({ children }: Props) {
<FullScreenLoading />
);
}
const CallBackSuccess = () => {
const params = useSearchParams();
const errorParam = params.get("error");
const currentPath = usePathname();
useRedirect(currentPath, true, !errorParam);
return <FullScreenLoading />;
};

View File

@@ -3,6 +3,24 @@ import { usePathname } from "next/navigation";
import * as React from "react";
import { useEffect } from "react";
const QUERY_PARAMS_KEY = "netbird-query-params";
const PRESERVE_QUERY_PARAMS_PATHS = ["/peer/ssh", "/peer/rdp"];
const VALID_PARAMS = [
"tab",
"search",
"id",
"invite",
"utm_source",
"utm_medium",
"utm_content",
"utm_campaign",
"hs_id",
"page",
"page_size",
"user",
"port",
];
type Props = {
children: React.ReactNode;
};
@@ -10,6 +28,22 @@ export const SecureProvider = ({ children }: Props) => {
const { isAuthenticated, login } = useOidc();
const currentPath = usePathname();
useEffect(() => {
if (isAuthenticated && !PRESERVE_QUERY_PARAMS_PATHS.includes(currentPath)) {
localStorage.removeItem(QUERY_PARAMS_KEY);
} else if (!isAuthenticated) {
try {
const params = window.location.search.substring(1);
if (params) {
const urlParams = new URLSearchParams(params);
if (VALID_PARAMS.some((param) => urlParams.has(param))) {
localStorage.setItem(QUERY_PARAMS_KEY, JSON.stringify(params));
}
}
} catch (e) {}
}
}, [isAuthenticated, currentPath]);
useEffect(() => {
let timeout: NodeJS.Timeout | undefined = undefined;
if (!isAuthenticated) {

View File

@@ -1,90 +0,0 @@
import { Checkbox } from "@components/Checkbox";
import { Input } from "@components/Input";
import { Popover, PopoverContent } from "@components/Popover";
import { useElementSize } from "@hooks/useElementSize";
import { Anchor } from "@radix-ui/react-popover";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { FaWindows } from "react-icons/fa6";
type Props = {};
export const AutoCompleteInput = ({}: Props) => {
const [open, setOpen] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const [elementWidth, { width }] = useElementSize<HTMLDivElement>();
useEffect(() => {
const input = inputRef.current;
const onFocus = () => {
setOpen(true);
};
if (input) {
inputRef.current.addEventListener("focus", onFocus);
}
return () => {
if (input) {
inputRef.current.removeEventListener("focus", onFocus);
}
};
}, []);
return (
<div className={"z-10 relative"}>
<Popover modal={false} open={open} onOpenChange={setOpen}>
<Anchor ref={elementWidth}>
<Input
placeholder={"11"}
ref={inputRef}
maxWidthClass={"max-w-[200px]"}
customPrefix={
<div className={"flex items-center gap-2"}>
<Checkbox></Checkbox>
<div
className={"flex gap-2 items-center text-sm text-nb-gray-200"}
>
<FaWindows className={"text-sky-600 text-lg"} />
Windows
</div>
</div>
}
/>
</Anchor>
<PopoverContent
hideWhenDetached={false}
className="w-full p-0 shadow-sm shadow-nb-gray-950"
style={{
width: width,
}}
forceMount={true}
align="start"
side={"bottom"}
sideOffset={10}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
onPointerDownOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
onFocusOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
></PopoverContent>
</Popover>
</div>
);
};

View File

@@ -32,6 +32,10 @@ const variants = cva("", {
green: ["bg-green-950 border-green-500 border text-green-400"],
netbird: ["bg-netbird-950 border-netbird-500 border text-netbird-500"],
},
size: {
default: "text-[0.75rem] py-1.5 px-3",
xs: "text-[0.6rem] py-[0.3rem] px-2",
},
hover: {
none: [],
blue: ["hover:bg-sky-200"],
@@ -42,7 +46,7 @@ const variants = cva("", {
red: ["hover:bg-red-950/40"],
gray: ["hover:bg-nb-gray-900"],
grayer: ["hover:bg-nb-gray-900"],
"gray-ghost": ["hover:bg-nb-gray-900"],
"gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"],
green: ["hover:bg-green-950/50"],
netbird: ["hover:bg-netbird-950/50"],
},
@@ -53,6 +57,7 @@ export default function Badge({
children,
className,
variant = "blue",
size = "default",
useHover = false,
disabled = false,
...props
@@ -60,8 +65,8 @@ export default function Badge({
return (
<div
className={cn(
"relative z-10 cursor-inherit whitespace-nowrap rounded-md text-[12px] py-1.5 px-3 font-normal flex gap-1.5 items-center justify-center transition-all",
variants({ variant, hover: useHover ? variant : "none" }),
"relative z-10 cursor-inherit whitespace-nowrap rounded-md font-normal flex gap-1.5 items-center justify-center transition-all",
variants({ variant, hover: useHover ? variant : "none", size }),
disabled && "cursor-not-allowed opacity-50 select-none",
className,
)}

View File

@@ -1,6 +1,6 @@
import { cn } from "@utils/helpers";
import { ChevronRightIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import React from "react";
type Props = {
@@ -25,8 +25,6 @@ export const Item = ({
active,
disabled = false,
}: ItemProps) => {
const router = useRouter();
return (
<div
className={cn(
@@ -45,7 +43,13 @@ export const Item = ({
)}
>
{icon && icon}
{href ? <span onClick={() => router.push(href)}>{label}</span> : label}
{href ? (
<Link href={href} data-cy={"breadcrumb-item"}>
{label}
</Link>
) : (
label
)}
</div>
</div>
);

View File

@@ -34,7 +34,7 @@ export const buttonVariants = cva(
secondary: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910",
],
secondaryLighter: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
@@ -54,7 +54,7 @@ export const buttonVariants = cva(
dotted: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-nb-gray-900/50",
],
tertiary: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
@@ -73,9 +73,13 @@ export const buttonVariants = cva(
"enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500",
"",
],
"danger-text": [
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50",
],
"default-outline": [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
"data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50",
],
danger: [
"", // TODO - add danger button styles for light mode

View File

@@ -34,7 +34,7 @@ const ButtonGroupButton = forwardRef(
border={2}
rounded={false}
className={cn(
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[41px]",
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[40px]",
"!py-2.5 !px-4",
className,
)}

View File

@@ -19,6 +19,8 @@ export const calloutVariants = cva(
default: "bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300",
warning: "bg-netbird-500/10 border-netbird-400/20 text-netbird-150",
info: "bg-sky-400/10 border-sky-400/20 text-sky-100",
success: "bg-green-400/15 border-green-400/20 text-green-100",
error: "bg-red-500/10 border-red-400/20 text-red-100",
},
},
},

View File

@@ -22,11 +22,7 @@ export default function CopyToClipboardText({
return (
<div
className={cn(
"flex gap-2 items-center group cursor-pointer transition-all hover:underline underline-offset-4 decoration-dashed decoration-nb-gray-600",
!copied && "hover:opacity-90",
className,
)}
className={cn("flex gap-2 items-center group cursor-pointer", className)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
@@ -34,27 +30,34 @@ export default function CopyToClipboardText({
}}
ref={wrapper}
>
{children}
<span className="relative truncate">
{children}
<span className="absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover:border-nb-gray-500 pointer-events-none" />
</span>
{copied ? (
<span
className={cn(
"shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
)}
>
<CheckIcon
className={cn(
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
!alwaysShowIcon && "opacity-0",
"text-nb-gray-100 group-hover:opacity-100",
!copied && "hidden",
!alwaysShowIcon && !copied && "opacity-0",
)}
size={11}
/>
) : (
<CopyIcon
className={cn(
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
"text-nb-gray-100 group-hover:opacity-100",
copied && "hidden",
!alwaysShowIcon && "opacity-0",
)}
size={11}
/>
)}
</span>
</div>
);
}

View File

@@ -15,6 +15,7 @@ interface Props {
value?: DateRange;
onChange?: (range: DateRange | undefined) => void;
className?: string;
disabled?: boolean;
}
const defaultRanges = {
@@ -61,6 +62,7 @@ export function DatePickerWithRange({
className,
value,
onChange,
disabled = false,
}: Readonly<Props>) {
const isActive = useMemo(() => {
return {
@@ -120,6 +122,7 @@ export function DatePickerWithRange({
<Button
id="date"
variant={"secondary"}
disabled={disabled}
className={cn("max-w-[260px] justify-start text-left font-normal")}
>
<CalendarIcon size={16} className={"shrink-0"} />

View File

@@ -0,0 +1,93 @@
import TruncatedText from "@components/ui/TruncatedText";
import { cn } from "@utils/helpers";
import * as React from "react";
import { useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { PeerOSIcon } from "@/assets/icons/PeerOSIcon";
import { ResourceIcon } from "@/assets/icons/ResourceIcon";
import { NetworkResource } from "@/interfaces/Network";
import type { Peer } from "@/interfaces/Peer";
type DeviceCardProps = {
device?: Peer;
resource?: NetworkResource;
className?: string;
address?: string;
description?: string;
};
export const DeviceCard = ({
device,
resource,
className,
address,
description,
}: DeviceCardProps) => {
if (!device && !resource) return null;
const descriptionText = useMemo(() => {
return description !== undefined
? description
: address || device?.ip || resource?.address;
}, [description, address, device]);
return (
<div
className={cn(
"flex shrink-0 items-center gap-2.5 text-nb-gray-200 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[230px]",
!descriptionText && "py-2",
className,
)}
>
<div
className={cn(
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-900 transition-all",
"group-hover:bg-nb-gray-800 relative",
)}
>
{device ? (
<PeerOSIcon os={device.os} />
) : resource?.type ? (
<ResourceIcon type={resource.type} />
) : null}
{device?.country_code && (
<div className={"absolute -bottom-[4px] -right-[4px]"}>
<div
className={cn(
"flex items-center justify-center rounded-full border-[3px] shrink-0",
"border-nb-gray-940",
)}
>
<RoundedFlag country={device?.country_code} size={10} />
</div>
</div>
)}
</div>
<div
className={
"flex flex-col gap-0 justify-center top-[0.15rem] leading-tight relative"
}
>
<span
className={
"font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
}
>
<TruncatedText
text={device?.name || resource?.name || "Unknown"}
maxWidth={"150px"}
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
}
>
<TruncatedText text={descriptionText} maxWidth={"160px"} />
</span>
</div>
</div>
);
};

View File

@@ -81,7 +81,7 @@ const menuItemVariants = cva("", {
variants: {
variant: {
default:
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-gray-400 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-nb-gray-300 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
danger:
"dark:focus:bg-red-900/20 dark:focus:text-red-500 dark:text-red-500",
},
@@ -93,25 +93,53 @@ const DropdownMenuItem = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "danger";
href?: string;
target?: string;
rel?: string;
}
>(({ className, inset, variant = "default", onClick, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
inset && "pl-8",
menuItemVariants({ variant }),
>(
(
{
className,
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick && onClick(e);
}}
{...props}
/>
));
inset,
variant = "default",
onClick,
href,
target,
rel,
...props
},
ref,
) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
inset && "pl-8",
menuItemVariants({ variant }),
className,
)}
onClick={(e) => {
if (href) return;
e.preventDefault();
e.stopPropagation();
onClick && onClick(e);
}}
{...props}
>
{href ? (
<a href={href} target={target} rel={rel}>
{props.children}
</a>
) : (
props.children
)}
</DropdownMenuPrimitive.Item>
);
},
);
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<

View File

@@ -0,0 +1,45 @@
import { cn } from "@utils/helpers";
import { ExternalLinkIcon } from "lucide-react";
import React from "react";
type Props = {
href: string;
children: React.ReactNode;
iconAlignment?: "left" | "right";
className?: string;
alwaysShowIcon?: boolean;
};
export default function ExternalLinkText({
href,
children,
iconAlignment = "right",
className,
alwaysShowIcon = false,
}: Props) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex gap-2 items-center group/link cursor-pointer hover:opacity-90",
className,
)}
onClick={(e) => e.stopPropagation()}
>
<span className="relative">
{children}
<span className="absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/link:border-nb-gray-500 pointer-events-none" />
</span>
<ExternalLinkIcon
className={cn(
"text-nb-gray-100 group-hover/link:opacity-100 shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
!alwaysShowIcon && "opacity-0",
)}
size={12}
/>
</a>
);
}

View File

@@ -99,7 +99,11 @@ export default function FancyToggleSwitch({
/>
</div>
</div>
<div>{children && value ? children : null}</div>
{children && value ? (
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
{children}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import * as React from "react";
import FullTooltip from "@components/FullTooltip";
type Props = {
content: React.ReactNode;
children: React.ReactNode;
interactive?: boolean;
};
export const HelpTooltip = ({
content,
children,
interactive = true,
}: Props) => {
return (
<>
<FullTooltip
interactive={interactive}
side={"top"}
align={"start"}
alignOffset={0}
className={
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
}
content={content}
>
{children}
</FullTooltip>
</>
);
};

View File

@@ -0,0 +1,43 @@
"use client";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@utils/helpers";
import * as React from "react";
import { TooltipVariants, tooltipVariants } from "./Tooltip";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> &
TooltipVariants
>(
(
{
className = "px-4 py-2.5",
sideOffset = 7,
side = "top",
variant = "default",
...props
},
ref,
) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
asChild={true}
side={side}
sideOffset={sideOffset}
className={cn(tooltipVariants({ variant }), className)}
{...props}
>
<div>{props.children}</div>
</HoverCardPrimitive.Content>
</HoverCardPrimitive.Portal>
),
);
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardContent, HoverCardTrigger };

View File

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

View File

@@ -2,8 +2,9 @@ import FullTooltip from "@components/FullTooltip";
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { cva, VariantProps } from "class-variance-authority";
import { AlertCircle } from "lucide-react";
import { AlertCircle, Eye, EyeOff } from "lucide-react";
import * as React from "react";
import { useState } from "react";
type InputVariants = VariantProps<typeof inputVariants>;
@@ -16,8 +17,9 @@ export interface InputProps
icon?: React.ReactNode;
error?: string;
errorTooltip?: boolean;
errorTooltipPosition?: "top" | "top-right";
errorTooltipPosition?: "top" | "top-right" | "bottom";
prefixClassName?: string;
showPasswordToggle?: boolean;
}
const inputVariants = cva("", {
@@ -61,10 +63,29 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
errorTooltipPosition = "top",
variant = "default",
prefixClassName,
showPasswordToggle = false,
...props
},
ref,
) => {
const [showPassword, setShowPassword] = useState(false);
const isPasswordType = type === "password";
const inputType = isPasswordType && showPassword ? "text" : type;
const passwordToggle =
isPasswordType && showPasswordToggle ? (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={"hover:text-white transition-all"}
aria-label={"Toggle password visibility"}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
) : null;
const suffix = passwordToggle || customSuffix;
return (
<>
<div className={cn("flex relative h-[42px]", maxWidthClass)}>
@@ -94,7 +115,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</div>
<input
type={type}
type={inputType}
ref={ref}
{...props}
className={cn(
@@ -103,9 +124,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
"file:border-0",
"focus-visible:ring-2 focus-visible:ring-offset-2",
customPrefix && "!border-l-0 !rounded-l-none",
customSuffix && "!pr-16",
suffix && "!pr-16",
icon && "!pl-10",
"border",
props.readOnly &&
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
className,
)}
/>
@@ -116,7 +139,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
props.disabled && "opacity-30",
)}
>
{customSuffix}
{suffix}
</div>
{error && errorTooltip && (
<div

View File

@@ -6,20 +6,37 @@ import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
const labelVariants = cva(
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1 inline-block dark:text-nb-gray-200 flex items-center gap-2",
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-200 flex items-center gap-2",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className, "select-none")}
{...props}
/>
));
type LabelProps = React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants> & {
as?: "label" | "div";
};
const Label = React.forwardRef<HTMLElement, LabelProps>(
({ className, as = "label", children, ...props }, ref) => {
const classes = cn(labelVariants(), className, "select-none");
if (as === "div") {
return (
<div ref={ref as React.Ref<HTMLDivElement>} className={classes}>
{children}
</div>
);
}
return (
<LabelPrimitive.Root
ref={ref as React.Ref<HTMLLabelElement>}
className={classes}
{...props}
>
{children}
</LabelPrimitive.Root>
);
},
);
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -6,24 +6,26 @@ export const ListItem = ({
label,
value,
className,
children,
}: {
icon?: React.ReactNode;
label: string;
value: string | React.ReactNode;
className?: string;
children?: React.ReactNode;
}) => {
return (
<div
className={cn(
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
className,
)}
className={cn(" border-b border-nb-gray-920 last:border-b-0", className)}
>
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
{icon}
{label}
<div className={cn("flex justify-between gap-12 py-2 px-4")}>
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
{icon}
{label}
</div>
<div className={"text-nb-gray-300"}>{value}</div>
</div>
<div className={"text-nb-gray-300"}>{value}</div>
{children}
</div>
);
};

View File

@@ -2,11 +2,11 @@ import { IconCircleX } from "@tabler/icons-react";
import type { ErrorResponse } from "@utils/api";
import { cn } from "@utils/helpers";
import classNames from "classnames";
import { AnimatePresence, motion } from "framer-motion";
import { motion } from "framer-motion";
import { CheckIcon, Loader2, XIcon } from "lucide-react";
import * as React from "react";
import { useEffect, useState } from "react";
import toast, { type Toast } from "react-hot-toast";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
export interface NotifyProps<T> {
title: string;
@@ -22,14 +22,15 @@ export interface NotifyProps<T> {
}
interface NotificationProps<T> extends NotifyProps<T> {
t: Toast;
toastId: string | number;
}
export default function Notification<T>({
title,
description,
icon,
backgroundColor,
t,
toastId,
promise,
loadingTitle,
loadingMessage,
@@ -39,17 +40,65 @@ export default function Notification<T>({
}: NotificationProps<T>) {
const [error, setError] = useState("");
const [loading, setLoading] = useState(!!promise);
const [readyToDismiss, setReadyToDismiss] = useState(!promise);
const [toastDuration] = useState(duration);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const remainingRef = useRef(duration);
const startTimeRef = useRef<number | null>(null);
const [preventSuccess, setPreventSuccess] = useState(false);
const startTimer = useCallback(() => {
if (timerRef.current) return;
startTimeRef.current = Date.now();
timerRef.current = setTimeout(() => {
timerRef.current = null;
toast.dismiss(toastId);
}, Math.max(0, remainingRef.current));
}, [toastId]);
const closeToast = () => {
setTimeout(() => {
setLoading(false);
toast.dismiss(t.id);
}, toastDuration);
};
const pauseTimer = useCallback(() => {
if (!timerRef.current || !startTimeRef.current) return;
clearTimeout(timerRef.current);
timerRef.current = null;
remainingRef.current = Math.max(
0,
remainingRef.current - (Date.now() - startTimeRef.current),
);
}, []);
const notificationRef = useRef<HTMLDivElement>(null);
// Watch for sonner's expanded state to pause/resume timer
useEffect(() => {
if (!readyToDismiss) return;
const toastEl = notificationRef.current?.closest(
"[data-sonner-toast]",
) as HTMLElement | null;
if (!toastEl) {
startTimer();
return;
}
const observer = new MutationObserver(() => {
const expanded = toastEl.getAttribute("data-expanded") === "true";
if (expanded) {
pauseTimer();
} else {
startTimer();
}
});
observer.observe(toastEl, { attributes: true, attributeFilter: ["data-expanded"] });
// Start immediately if not expanded
const expanded = toastEl.getAttribute("data-expanded") === "true";
if (!expanded) startTimer();
return () => {
observer.disconnect();
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [readyToDismiss, toastId, startTimer, pauseTimer]);
useEffect(() => {
// Run the promise
@@ -57,8 +106,11 @@ export default function Notification<T>({
promise
.then(() => {
setLoading(false);
closeToast();
if (preventSuccessToast) setPreventSuccess(true);
if (preventSuccessToast) {
toast.dismiss(toastId);
} else {
setReadyToDismiss(true);
}
})
.catch((e) => {
const err = e as ErrorResponse;
@@ -78,78 +130,76 @@ export default function Notification<T>({
}
setLoading(false);
closeToast();
setReadyToDismiss(true);
});
} else {
closeToast();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<AnimatePresence>
{t.visible && !preventSuccess && (
<motion.div
initial={{ opacity: 1, y: -50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
className={cn(
"max-w-md w-full justify-between bg-white dark:bg-nb-gray-940 shadow-lg rounded-md px-4 py-2.5 pointer-events-auto flex border dark:border-nb-gray-900",
)}
>
<div className={"flex items-center gap-4"}>
<div
className={classNames(
"h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0",
loading
? "bg-nb-gray-900"
: error
? "bg-red-500"
: backgroundColor || "bg-green-500",
)}
>
{loading ? (
<Loader2 size={14} className={"animate-spin"} />
) : error ? (
<IconCircleX size={24} />
) : (
icon || <CheckIcon size={14} />
)}
</div>
<div className={"flex flex-col text-sm"}>
<p>
<span className={"font-semibold"}>
{loading ? loadingTitle || title : title}
</span>
</p>
<p
className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}
>
{loading ? loadingMessage : error ? error : description}
</p>
</div>
</div>
<button
className="flex dark:border-nb-gray-900 items-center cursor-pointer group"
onClick={() => toast.dismiss(t.id)}
<motion.div
ref={notificationRef}
initial={{ y: -20 }}
animate={{ y: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 20 }}
data-toast-notification
className="w-[28rem] pb-2"
>
<div
className={cn(
"w-full justify-between bg-white dark:bg-nb-gray-940 shadow-lg rounded-md px-4 py-2.5 pointer-events-auto flex border dark:border-nb-gray-900",
)}
>
<div className={"flex items-center gap-4"}>
<div
className={classNames(
"h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0",
loading
? "bg-nb-gray-900"
: error
? "bg-red-500"
: backgroundColor || "bg-green-500",
)}
>
<div
className={
"p-2 hover:bg-nb-gray-900 rounded-md opacity-50 group-hover:opacity-100"
}
>
<XIcon size={16} />
</div>
</button>
</motion.div>
)}
</AnimatePresence>
{loading ? (
<Loader2 size={14} className={"animate-spin"} />
) : error ? (
<IconCircleX size={24} />
) : (
icon || <CheckIcon size={14} />
)}
</div>
<div className={"flex flex-col text-sm"}>
<p>
<span className={"font-semibold"}>
{loading ? loadingTitle || title : title}
</span>
</p>
<p className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}>
{loading ? loadingMessage : error ? error : description}
</p>
</div>
</div>
<button
className="flex dark:border-nb-gray-900 items-center cursor-pointer group"
onClick={() => toast.dismiss(toastId)}
>
<div
className={
"p-2 hover:bg-nb-gray-900 rounded-md opacity-50 group-hover:opacity-100"
}
>
<XIcon size={16} />
</div>
</button>
</div>
</motion.div>
);
}
export function notify<T>(props: NotifyProps<T>) {
return toast.custom((t) => <Notification {...props} t={t} />, {
return toast.custom((id) => <Notification {...props} toastId={id} />, {
duration: Infinity,
});
}

View File

@@ -42,8 +42,11 @@ import { NetworkResource } from "@/interfaces/Network";
import type { Peer } from "@/interfaces/Peer";
import { PolicyRuleResource } from "@/interfaces/Policy";
import { User } from "@/interfaces/User";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
import TruncatedText from "@components/ui/TruncatedText";
type PeerGroupSelectorTab = "peers" | "groups" | "resources";
const groupsSearchPredicate = (item: Group, query: string) => {
const lowerCaseQuery = query.toLowerCase();
@@ -68,14 +71,18 @@ interface MultiSelectProps {
showResourceCounter?: boolean;
showResources?: boolean;
showPeers?: boolean;
hideGroupsTab?: boolean;
tabOrder?: ("groups" | "peers" | "resources")[];
closeOnSelect?: boolean;
resource?: PolicyRuleResource;
onResourceChange?: (resource?: PolicyRuleResource) => void;
placeholder?: string;
placeholder?: React.ReactNode | string;
customTrigger?: React.ReactNode;
align?: "start" | "end";
side?: "top" | "bottom";
users?: User[];
placeholderForSearch?: string;
resourceIds?: string[];
}
export function PeerGroupSelector({
onChange,
@@ -94,6 +101,9 @@ export function PeerGroupSelector({
showResourceCounter = true,
showResources = false,
showPeers = false,
hideGroupsTab = false,
tabOrder,
closeOnSelect = false,
resource,
onResourceChange,
placeholder = "Add or select group(s)...",
@@ -102,6 +112,7 @@ export function PeerGroupSelector({
side = "bottom",
users,
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
resourceIds,
}: Readonly<MultiSelectProps>) {
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
@@ -171,7 +182,15 @@ export function PeerGroupSelector({
const groupResources: GroupResource[] | undefined =
(group?.resources as GroupResource[]) || [];
if (peer) groupPeers?.push({ id: peer?.id as string, name: peer?.name });
if (peer) {
const peerInGroup = groupPeers?.find((p) => p?.id === peer?.id);
if (!peerInGroup) {
groupPeers?.push({
id: peer?.id as string,
name: peer?.name,
});
}
}
if (!group && !option) {
addDropdownOptions([
@@ -221,7 +240,13 @@ export function PeerGroupSelector({
const [slice, setSlice] = useState(10);
const [tab, setTab] = useState("groups");
const getDefaultTab = (): PeerGroupSelectorTab => {
if (tabOrder?.[0]) return tabOrder[0];
if (hideGroupsTab) return showPeers ? "peers" : "resources";
return "groups";
};
const [tab, setTab] = useState<PeerGroupSelectorTab>(getDefaultTab);
useEffect(() => {
if (open) {
@@ -264,6 +289,9 @@ export function PeerGroupSelector({
: undefined,
);
onChange([]);
if (closeOnSelect) {
setOpen(false);
}
};
const selectPeer = (peer?: Peer) => {
@@ -273,6 +301,9 @@ export function PeerGroupSelector({
type: "peer",
});
onChange([]);
if (closeOnSelect) {
setOpen(false);
}
};
return (
@@ -309,7 +340,7 @@ export function PeerGroupSelector({
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
}
>
{resource && showResources && (
{resource && (
<ResourceBadge
className={"py-[3px]"}
resource={resources?.find((r) => r.id === resource.id)}
@@ -366,7 +397,9 @@ export function PeerGroupSelector({
})}
{values.length == 0 && !resource && (
<span className={"pl-1"}>{placeholder}</span>
<span className={cn(typeof placeholder === "string" && "pl-1")}>
{placeholder}
</span>
)}
</div>
@@ -430,11 +463,20 @@ export function PeerGroupSelector({
</div>
</div>
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
<Tabs
defaultValue={
tabOrder?.[0] ??
(hideGroupsTab ? (showPeers ? "peers" : "resources") : "groups")
}
value={tab}
onValueChange={(v) => setTab(v as PeerGroupSelectorTab)}
>
<TabTriggers
searchRef={searchRef}
showPeers={showPeers}
showResources={showResources}
hideGroupsTab={hideGroupsTab}
tabOrder={tabOrder}
/>
<TabsContent value={"groups"} className={"p-0 my-0"}>
<CommandGroup>
@@ -518,7 +560,7 @@ export function PeerGroupSelector({
/>
</div>
<div className={"flex items-center gap-5"}>
<div className={"flex items-center gap-4"}>
{option?.id && showRoutes && (
<AccessControlGroupCount group_id={option.id} />
)}
@@ -527,19 +569,12 @@ export function PeerGroupSelector({
<ResourcesCounter group={option} />
)}
<div className={"flex gap-3 items-center"}>
<div className={"flex gap-4 items-center"}>
{!users ? (
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
<MonitorSmartphoneIcon
size={14}
className={"shrink-0"}
/>
{peerCount} Peer(s)
</div>
<PeerCounter
group={option}
showResourceCounter={showResourceCounter}
/>
) : (
<UsersCounter
group={option}
@@ -547,7 +582,6 @@ export function PeerGroupSelector({
selected={isSelected}
/>
)}
<Checkbox checked={isSelected} />
</div>
</div>
@@ -562,7 +596,11 @@ export function PeerGroupSelector({
<TabsContent value={"resources"} className={"p-0 my-0"}>
<ResourcesList
search={search}
resources={resources}
resources={
resourceIds
? resources?.filter((r) => resourceIds.includes(r.id))
: resources
}
isLoading={isResourcesLoading}
value={resource}
onChange={selectResource}
@@ -592,60 +630,89 @@ const TabTriggers = ({
searchRef,
showResources = false,
showPeers = false,
hideGroupsTab = false,
tabOrder,
}: {
searchRef: React.MutableRefObject<HTMLInputElement | null>;
showResources?: boolean;
showPeers?: boolean;
hideGroupsTab?: boolean;
tabOrder?: ("groups" | "peers" | "resources")[];
}) => {
if (!showResources && !showPeers) return null;
const tabCount =
(!hideGroupsTab ? 1 : 0) + (showResources ? 1 : 0) + (showPeers ? 1 : 0);
if (tabCount <= 1) return null;
const groupsTab = !hideGroupsTab && (
<TabsTrigger
key="groups"
value={"groups"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<FolderGit2
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Groups
</TabsTrigger>
);
const resourcesTab = showResources && (
<TabsTrigger
key="resources"
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resources
</TabsTrigger>
);
const peersTab = showPeers && (
<TabsTrigger
key="peers"
value={"peers"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<MonitorSmartphoneIcon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Peers
</TabsTrigger>
);
const tabMap = {
groups: groupsTab,
peers: peersTab,
resources: resourcesTab,
};
if (tabOrder) {
return (
<TabsList justify={"start"} className={"px-3"}>
{tabOrder.map((tab) => tabMap[tab])}
</TabsList>
);
}
return (
<TabsList justify={"start"} className={"px-3"}>
<TabsTrigger
value={"groups"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<FolderGit2
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Groups
</TabsTrigger>
{showResources && (
<TabsTrigger
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resources
</TabsTrigger>
)}
{showPeers && (
<TabsTrigger
value={"peers"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<MonitorSmartphoneIcon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Peers
</TabsTrigger>
)}
{groupsTab}
{resourcesTab}
{peersTab}
</TabsList>
);
};
@@ -663,7 +730,14 @@ const UsersCounter = ({
users?.filter((user) => user.auto_groups.includes(group.id as string)) ||
[];
if (usersOfGroup.length === 0) return null;
if (usersOfGroup.length === 0)
return (
<span
className={"group-hover/user-stack:text-nb-gray-200 text-nb-gray-300"}
>
0 User(s)
</span>
);
return (
<HorizontalUsersStack
@@ -678,6 +752,31 @@ const UsersCounter = ({
);
};
const PeerCounter = ({
group,
showResourceCounter,
}: {
group: Group;
showResourceCounter?: boolean;
}) => {
const peerCount = group.peers?.length ?? group?.peers_count ?? 0;
const resourcesCount = group?.resources_count ?? 0;
const hidePeerCounter =
showResourceCounter && peerCount === 0 && resourcesCount > 0;
return (
<div
className={cn(
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2",
hidePeerCounter && "hidden",
)}
>
<MonitorSmartphoneIcon size={14} className={"shrink-0"} />
{peerCount} Peer(s)
</div>
);
};
const ResourcesCounter = ({ group }: { group: Group }) => {
return group?.resources_count && group.resources_count > 0 ? (
<div
@@ -755,6 +854,7 @@ const ResourcesList = ({
<VirtualScrollAreaList
items={filteredItems}
onSelect={onChange}
estimatedItemHeight={42}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => {
return (
@@ -864,6 +964,7 @@ const PeersList = ({
<VirtualScrollAreaList
items={filteredItems}
onSelect={onChange}
estimatedItemHeight={42}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => {
if (!res?.id) return;
@@ -872,7 +973,7 @@ const PeersList = ({
<Fragment key={res.id}>
<div className={"flex items-center gap-2"}>
<Badge
useHover={true}
useHover={false}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn(
@@ -883,7 +984,7 @@ const PeersList = ({
}}
>
<PeerOperatingSystemIcon os={res.os} />
<TextWithTooltip text={res?.name || ""} maxChars={20} />
<TruncatedText text={res?.name || ""} maxWidth={"270px"} />
</Badge>
</div>

View File

@@ -13,7 +13,6 @@ import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react";
import * as React from "react";
import { memo, useEffect, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";

View File

@@ -0,0 +1,123 @@
"use client";
import { cn } from "@utils/helpers";
import React, {
ClipboardEvent,
forwardRef,
KeyboardEvent,
useImperativeHandle,
useRef,
} from "react";
export interface PinCodeInputRef {
focus: () => void;
}
interface Props {
value: string;
onChange: (value: string) => void;
length?: number;
disabled?: boolean;
className?: string;
type?: "text" | "password";
}
const PinCodeInput = forwardRef<PinCodeInputRef, Props>(function PinCodeInput(
{ value, onChange, length = 6, disabled = false, className, type = "text" },
ref,
) {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
useImperativeHandle(ref, () => ({
focus: () => {
inputRefs.current[0]?.focus();
},
}));
const digits = value
.split("")
.concat(Array(length).fill(""))
.slice(0, length);
const handleChange = (index: number, digit: string) => {
if (!/^\d*$/.test(digit)) return;
const newDigits = [...digits];
newDigits[index] = digit.slice(-1);
const newValue = newDigits.join("").replace(/\s/g, "");
onChange(newValue);
if (digit && index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace" && !digits[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
if (e.key === "ArrowLeft" && index > 0) {
inputRefs.current[index - 1]?.focus();
}
if (e.key === "ArrowRight" && index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
if (/^\d$/.test(e.key) && digits[index]) {
e.preventDefault();
const newDigits = [...digits];
newDigits[index] = e.key;
onChange(newDigits.join("").replace(/\s/g, ""));
if (index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
}
};
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pastedData = e.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, length);
onChange(pastedData);
const nextIndex = Math.min(pastedData.length, length - 1);
inputRefs.current[nextIndex]?.focus();
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.select();
};
return (
<div className={cn("flex gap-2", className)}>
{digits.map((digit, index) => (
<input
key={index}
ref={(el) => {
inputRefs.current[index] = el;
}}
type={type}
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={handlePaste}
onFocus={handleFocus}
disabled={disabled}
className={cn(
"w-[42px] h-[42px] text-center text-sm rounded-md",
"dark:bg-nb-gray-900 border dark:border-nb-gray-700",
"dark:placeholder:text-neutral-400/70",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
"disabled:cursor-not-allowed disabled:opacity-40",
)}
/>
))}
</div>
);
});
export default PinCodeInput;

View File

@@ -139,7 +139,11 @@ export function PortSelector({
<Badge
key={x}
variant={"gray"}
onClick={() => toggle(x)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggle(x);
}}
className={"uppercase tracking-wider font-medium py-1"}
>
{x}
@@ -184,7 +188,6 @@ export function PortSelector({
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)}
data-cy={"port-input"}
typeof={"number"}
ref={searchRef}
value={search}
onValueChange={setSearch}

View File

@@ -1,7 +1,6 @@
import * as RadixRadioGroup from "@radix-ui/react-radio-group";
import { cn } from "@utils/helpers";
import * as React from "react";
import { useState } from "react";
type Props = {
value: string;
@@ -10,10 +9,8 @@ type Props = {
};
export const RadioGroup = ({ value, onChange, children }: Props) => {
const [defaultValue] = useState(value);
return (
<RadixRadioGroup.Root
defaultValue={defaultValue}
value={value}
onValueChange={onChange}
className={

View File

@@ -0,0 +1,103 @@
"use client";
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import { Label } from "@components/Label";
import { SmallBadge } from "@components/ui/SmallBadge";
import { cn } from "@utils/helpers";
import { PlusCircle, SquarePen } from "lucide-react";
import React from "react";
type SettingCardItemProps = {
label: React.ReactNode;
description: React.ReactNode;
enabled: boolean;
onClick: () => void;
};
function SettingCardItem({
label,
description,
enabled,
onClick,
}: Readonly<SettingCardItemProps>) {
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
className={
"flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 hover:bg-nb-gray-935 cursor-pointer transition-colors"
}
>
<div className={"max-w-sm"}>
<div className="flex items-center gap-2">
<Label>{label}</Label>
{enabled && (
<SmallBadge
text="Enabled"
variant="green"
size="md"
className={"-top-[0.25rem]"}
/>
)}
</div>
<HelpText margin={false}>{description}</HelpText>
</div>
<div onClick={(e) => e.stopPropagation()}>
{enabled ? (
<Button
variant={"secondaryLighter"}
size={"xs"}
className={"pl-3 pr-3"}
onClick={onClick}
>
<SquarePen size={12} />
Edit
</Button>
) : (
<Button
variant={"secondaryLighter"}
size={"xs"}
className={"pl-3 pr-3"}
onClick={onClick}
>
<PlusCircle size={12} />
Add
</Button>
)}
</div>
</div>
);
}
type SettingCardProps = {
children: React.ReactNode;
className?: string;
};
function SettingCard({ children, className }: Readonly<SettingCardProps>) {
return (
<div
className={cn(
"border-nb-gray-920 bg-nb-gray-800/10 border rounded-md",
className,
)}
>
{children}
</div>
);
}
const SettingCardWithItem = SettingCard as React.FC<Readonly<SettingCardProps>> & {
Item: typeof SettingCardItem;
};
SettingCardWithItem.Item = SettingCardItem;
export default SettingCardWithItem;

View File

@@ -5,7 +5,7 @@ import { cn } from "@utils/helpers";
import classNames from "classnames";
import { ChevronDownIcon, ChevronUpIcon, DotIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import React, { useMemo } from "react";
import React, { useEffect, useMemo } from "react";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
export type SidebarItemProps = {
@@ -36,8 +36,22 @@ export default function SidebarItem({
labelClassName,
visible,
}: Readonly<SidebarItemProps>) {
const [open, setOpen] = React.useState(false);
const path = usePathname();
// Check if any child route is active (for collapsible items)
const hasActiveChild = useMemo(() => {
if (!collapsible || !href) return false;
return path === href || path.startsWith(href + "/");
}, [collapsible, href, path]);
const [open, setOpen] = React.useState(hasActiveChild);
// Open the collapsible if a child route becomes active
useEffect(() => {
if (hasActiveChild && !open) {
setOpen(true);
}
}, [hasActiveChild]);
const router = useRouter();
const { mobileNavOpen, toggleMobileNav, isNavigationCollapsed } =
useApplicationContext();
@@ -48,6 +62,7 @@ export default function SidebarItem({
? path == href
: path.includes(href)
: false;
if (collapsible && href) return;
if (collapsible && mobileNavOpen) return;
if (collapsible && open) return;
if (preventRedirect) return;
@@ -66,7 +81,7 @@ export default function SidebarItem({
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Collapsible.Trigger asChild>
<li className={"px-4 cursor-pointer list-none"}>
<li className={"px-3 cursor-pointer list-none"}>
<button
className={classNames(
"rounded-lg text-[.87rem] w-full relative font-normal",
@@ -79,6 +94,7 @@ export default function SidebarItem({
: "text-gray-600 hover:bg-gray-200 dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/50",
)}
onClick={handleClick}
data-cy={"left-navigation-item"}
>
{isChild && isNavigationCollapsed && !mobileNavOpen && (
<div
@@ -100,7 +116,7 @@ export default function SidebarItem({
<span
className={cn(
"px-4 whitespace-nowrap flex-1 w-full text-left",
"px-3 whitespace-nowrap flex-1 w-full text-left",
labelClassName,
isNavigationCollapsed &&
!mobileNavOpen &&

View File

@@ -39,38 +39,43 @@ const Tabs = React.forwardRef<
Tabs.displayName = TabsPrimitive.Root.displayName;
type TabListProps = {
hidden?: boolean;
justify?: "start" | "end" | "center" | "between";
};
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & TabListProps
>(({ className, justify = "center", ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"flex flex-nowrap text-neutral-500 dark:text-nb-gray-400 w-full relative",
className,
justify == "center" && "justify-center justify-items-end",
justify == "start" && "justify-start",
justify == "end" && "justify-end",
justify == "between" && "justify-between",
)}
{...props}
>
<span
className={
"absolute left-0 dark:bg-nb-gray-900 bg-nb-gray-100 w-full h-[1px] bottom-0 z-0"
}
/>
<ScrollArea>
<div className={"relative z-[1] flex flex-nowrap w-full "}>
{props.children}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</TabsPrimitive.List>
));
>(({ className, justify = "center", hidden = false, ...props }, ref) => {
return (
!hidden && (
<TabsPrimitive.List
ref={ref}
className={cn(
"flex flex-nowrap text-neutral-500 dark:text-nb-gray-400 w-full relative",
className,
justify == "center" && "justify-center justify-items-end",
justify == "start" && "justify-start",
justify == "end" && "justify-end",
justify == "between" && "justify-between",
)}
{...props}
>
<span
className={
"absolute left-0 dark:bg-nb-gray-900 bg-nb-gray-100 w-full h-[1px] bottom-0 z-0"
}
/>
<ScrollArea>
<div className={"relative z-[1] flex flex-nowrap w-full "}>
{props.children}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</TabsPrimitive.List>
)
);
});
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<

View File

@@ -22,14 +22,14 @@ export const tooltipVariants = cva(
variants: {
variant: {
default: [
"bg-white dark:bg-nb-gray-940",
"text-neutral-950 dark:text-neutral-50",
"border-neutral-200 dark:border-nb-gray-930",
"bg-nb-gray-940",
"text-neutral-50",
"border-neutral-200 border-nb-gray-930",
],
lighter: [
"bg-white dark:bg-nb-gray-920",
"text-neutral-950 dark:text-neutral-50",
"border-neutral-200 dark:border-nb-gray-900",
"bg-nb-gray-920",
"text-neutral-50",
"border-neutral-200 border-nb-gray-900",
],
},
},
@@ -53,13 +53,10 @@ const TooltipContent = React.forwardRef<
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
asChild={true}
sideOffset={sideOffset}
className={cn(tooltipVariants({ variant }), className)}
{...props}
>
<div>{props.children}</div>
</TooltipPrimitive.Content>
/>
</TooltipPrimitive.Portal>
),
);

View File

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

View File

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

View File

@@ -1,12 +1,22 @@
import {
MemoizedScrollArea,
MemoizedScrollAreaViewport,
} from "@components/ScrollArea";
import { MemoizedScrollArea, ScrollAreaViewport } from "@components/ScrollArea";
import { cn } from "@utils/helpers";
import * as React from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
forwardRef,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
const VirtuosoScroller = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>((props, ref) => <ScrollAreaViewport ref={ref} {...props} />);
type Props<T extends { id?: string }> = {
items: T[];
onSelect: (item: T) => void;
@@ -183,7 +193,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
}}
style={virtuosoHeight}
components={{
Scroller: MemoizedScrollAreaViewport,
Scroller: VirtuosoScroller,
}}
/>
</MemoizedScrollArea>

View File

@@ -2,6 +2,7 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { DialogTriggerProps } from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { cn } from "@utils/helpers";
import { X } from "lucide-react";
import * as React from "react";
@@ -58,6 +59,7 @@ const ModalContent = React.forwardRef<
children,
showClose = true,
maxWidthClass = "max-w-3xl",
onPointerDownOutside,
...props
},
ref,
@@ -71,21 +73,35 @@ const ModalContent = React.forwardRef<
className,
maxWidthClass,
)}
onPointerDownOutside={(e) => {
// Prevent closing modal when clicking on toast notifications
try {
const target = e.target as HTMLElement;
if (target?.closest("[data-toast-notification]")) {
e.preventDefault();
return;
}
} catch {
// Ignore errors
}
onPointerDownOutside?.(e);
}}
{...props}
onClick={(e) => e.stopPropagation()}
>
<>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 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>
)}
</>
<VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
</VisuallyHidden>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 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>
)}
</DialogPrimitive.Content>
</ModalOverlay>
</ModalPortal>
@@ -129,18 +145,19 @@ const SidebarModalContent = React.forwardRef<
}}
onClick={(e) => e.stopPropagation()}
>
<>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 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>
)}
</>
<VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
</VisuallyHidden>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 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>
)}
</DialogPrimitive.Content>
</div>
</ModalPortal>

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