Compare commits

...

33 Commits

Author SHA1 Message Date
Eduard Gert
01330e0f58 Fix missing peer context in group network routes tab (#620)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-04-23 17:05:05 +02:00
Viktor Liu
e9ac1a1a23 Add CrowdSec IP reputation (#600)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-04-21 12:29:37 +02:00
raghvendra
b53802a5c5 fix: prevent storage clear and logout on failed account deletion (#611) 2026-04-13 09:07:14 +02:00
Eduard Gert
9addc18956 Fix reverse proxy mode selection (#606)
* Fix reverse proxy mode selection

* Fix isNetBirdHosted

* Fix activity description
2026-04-09 09:52:35 +02:00
shuuri-labs
9701e6503b Add new pull request template + enforce documentation acknowledgement… (#602)
* Add new pull request template + enforce documentation acknowledgement in new workflow

* fix docs-ack workflow: pass PR number via env and simplify checkbox validation
2026-04-02 21:39:38 +02:00
Eduard Gert
0841caecbb Fix dns zone domain validation and peers last seen sort (#595)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-25 17:50:22 +01:00
Eduard Gert
c7846760d1 Add reverse proxy auth headers (#593)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments

* Add auth header modal

* Remove password managers from auth headers

* fix unique id

* Remove gradient, fix button roundness

* update lucide, add additional event auth methods

* Clear existing header value on change
2026-03-25 14:31:36 +01:00
Viktor Liu
8c283b6ef9 Support optional subdomain for reverse proxy domains (#589) 2026-03-24 16:01:01 +01:00
Eduard Gert
34ae3b4da6 Add reverse proxy access rules (#592)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments
2026-03-24 16:00:31 +01:00
Viktor Liu
aff2365ef7 Add layer 4 protocol support to reverse proxy (#579)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add layer 4 proto support

* Fix initialResource fallback and UDP session_idle_timeout

* Fix tlsResourceId init for resource-driven create flows, UDP timeout label

* Address PR review: ServiceMode enum, resource init fix, modal title, a11y

* Add L4 protocol values to ReverseProxyTargetProtocol, remove unsafe double cast

* Add aria-labels to L4 port/host inputs

* Unify domain input for all service modes including L4

* Support L4 proxy events

* Fix custom port reset on edit and show port in L4 service link

* Remove redundant listen port from L4 target cell

* Show link only for HTTP/TLS services, copy-on-click for TCP/UDP

* Move mode badge before domain and use fixed width for alignment

* Fix HTTP services to open as link instead of copy

* Hide old proxy clusters from L4 domain selector

* Move service type inside modal

* Update auth cell

* Add target selector component

* Extract into separate components

* hide services types for not supported clusters

* Remove advanced settings tab in http targetmodal and use accordion instead

* Update advanced settings

* Update target device row

* Update text

* Add type cell

* Fix flat target name cell

* Update modal title

* Fix edit target in flat table

* Remove unused proxycluster interface

* Move proxy type icon into type component

* sync cloud

* use emptyrow

* fix l4 type

* fix duplicate error notification

* Set the correct target type

* Fix subnet host editable

* Fix subnet host editable

* hide selector when initial resource or peer

* Rename dropdown

* Update text

* update status cell

* merge cloud

* Update tooltips

* Address coderabbit comments

* Fix skeleton device card

* Update listen port tooltip

* Adjust padding

* update package-lock.json

* bump next to 16.1.7

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-03-18 17:43:00 +01:00
Zoltan Papp
bad057d415 [dashboard] feat: add auto_update_always toggle to client settings (#580)
* [dashboard] feat: add auto_update_always toggle to client settings

Add "Always Update" toggle to the Clients settings tab that controls
whether updates are installed automatically in the background or require
user interaction from the UI. Includes a warning icon and caution callout
when enabled to highlight the risk of disrupting active connections.

* [dashboard] fix: improve auto-update UI clarity and toggle label

Clarify that automatic updates require user interaction by updating the
description. Rename "Always Update" to "Force Automatic Updates" for
clarity. Move warning callout below the toggle switch instead of inside it.

* Update src/modules/settings/ClientSettingsTab.tsx

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

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-03-16 15:35:44 +01:00
Misha Bragin
4d846e2c94 Improve text for optional resource setiings (#584) 2026-03-12 20:48:09 +01:00
Eduard Gert
15fb6e0b05 Refactor resource modal (#582)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-12 16:30:51 +01:00
Eduard Gert
55c5525626 Fix resource group policy when adding single resource as destination (#581)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-11 19:23:59 +01:00
Eduard Gert
c0c1f4688e Add proxy events sort (#560)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add proxy events sort

* Fix coderabbit comment

* Disable local sort when server pagination is used
2026-03-10 10:10:53 +01:00
Eduard Gert
b5a8f751ba Create policies inside resources (#568)
* Add acl tooltips

* Adjust resource modal and add tooltips

* Prevent nextjs navigation trigger on tab change

* Update wording

* add acl into resource

* Refactor resource policies

* Add prop to hide group edit and disable redirect

* Add skeleton loader to network page

* Create policy for new resources

* Show existing policies if groups are matching

* Add confirm dialog after creating resource without policy

* Add dialog if user edits policy that is used in multiple resources

* Add callout when selecting resource groups containing policies

* Add dialog if deleting policies containing resources

* Fix stale policies and new group creation in resource modal

* Remove whitespace

* Fix sort

* Cleanup

* Address coderabbit comments

* Fix policy alignment

* Fix initial resource

* disable selector if user did not select  resource groups

* Consider current resource when editing / deleting policy

* Remove unused mutate

* Fix dot position

* Remove ask for policy

* Fix policy index

* Fix multiple resource confirm dialog on policy cell
2026-03-10 10:10:38 +01:00
Eduard Gert
10a8e7b745 Fix stale certificate issued state (#575)
* Fix stale certificate issued state

* fix coderabbit
2026-03-09 10:08:35 +01:00
Viktor Liu
60e8394010 Add per-target options to reverse proxy (#576) 2026-03-06 18:55:28 +01:00
Eduard Gert
9420214059 Bump minimatch and ajv dependencies (#572) 2026-03-02 11:32:52 +01:00
Maycon Santos
b949f60afe Feature/client service expose (#567)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* add draft

* add reverse proxy activities

* move peer expose settings into client settings tab and fix activity descriptions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* prevent false positive group report

* add docs link

* allow save when groups are added to the setting

* Add loading skeleton to client settings, update icon, use grouphelper to allow creating new groups, remove .patch

* mv expose settings from extra settings

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-02-24 14:54:58 +01:00
Eduard Gert
d498e4cc25 Fix dns records pagination (#566)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-20 21:42:26 +01:00
Eduard Gert
130dc0c32c Fix group unused filter (#565)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-19 10:32:14 +01:00
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
217 changed files with 15960 additions and 4477 deletions

12
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
## Issue ticket number and link
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [ ] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__

View File

@@ -19,7 +19,7 @@ jobs:
- name: setup-node
uses: actions/setup-node@v3
with:
node-version: '18'
node-version: '20'
cache: 'npm'
- name: Install dependencies

105
.github/workflows/docs-ack.yml vendored Normal file
View File

@@ -0,0 +1,105 @@
name: Docs Acknowledgement
on:
pull_request:
types: [opened, edited, synchronize]
permissions:
contents: read
pull-requests: read
jobs:
docs-ack:
name: Require docs PR URL or explicit "not needed"
runs-on: ubuntu-latest
steps:
- name: Read PR body
id: body
shell: bash
run: |
set -euo pipefail
BODY_B64=$(jq -r '.pull_request.body // "" | @base64' "$GITHUB_EVENT_PATH")
{
echo "body_b64=$BODY_B64"
} >> "$GITHUB_OUTPUT"
- name: Validate checkbox selection
id: validate
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
if ! body="$(printf '%s' "$BODY_B64" | base64 -d)"; then
echo "::error::Failed to decode PR body from base64. Data may be corrupted or missing."
exit 1
fi
added_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*I added/updated documentation' | wc -l | tr -d '[:space:]' || true)
noneed_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*Documentation is \*\*not needed\*\*' | wc -l | tr -d '[:space:]' || true)
total=$((added_checked + noneed_checked))
if [ "$total" -ne 1 ]; then
echo "::error::You must check exactly one docs option in the PR template (either 'docs added' OR 'not needed')."
exit 1
fi
if [ "$added_checked" -eq 1 ]; then
echo "mode=added" >> "$GITHUB_OUTPUT"
else
echo "mode=noneed" >> "$GITHUB_OUTPUT"
fi
- name: Extract docs PR URL (when 'docs added')
if: steps.validate.outputs.mode == 'added'
id: extract
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
body="$(printf '%s' "$BODY_B64" | base64 -d)"
# Strictly require HTTPS and that it's a PR in netbirdio/docs
# e.g., https://github.com/netbirdio/docs/pull/1234
url="$(printf '%s' "$body" | grep -Eo 'https://github\.com/netbirdio/docs/pull/[0-9]+' | head -n1 || true)"
if [ -z "${url:-}" ]; then
echo "::error::You checked 'docs added' but didn't include a valid HTTPS PR link to netbirdio/docs (e.g., https://github.com/netbirdio/docs/pull/1234)."
exit 1
fi
pr_number="$(printf '%s' "$url" | sed -E 's#.*/pull/([0-9]+)$#\1#')"
{
echo "url=$url"
echo "pr_number=$pr_number"
} >> "$GITHUB_OUTPUT"
- name: Verify docs PR exists (and is open or merged)
if: steps.validate.outputs.mode == 'added'
uses: actions/github-script@v7
id: verify
env:
PR_NUMBER: ${{ steps.extract.outputs.pr_number }}
with:
script: |
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const { data } = await github.rest.pulls.get({
owner: 'netbirdio',
repo: 'docs',
pull_number: prNumber
});
// Allow open or merged PRs
const ok = data.state === 'open' || data.merged === true;
core.setOutput('state', data.state);
core.setOutput('merged', String(!!data.merged));
if (!ok) {
core.setFailed(`Docs PR #${prNumber} exists but is neither open nor merged (state=${data.state}, merged=${data.merged}).`);
}
result-encoding: string
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: All good
run: echo "Documentation requirement satisfied ✅"

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

@@ -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,10 +18,10 @@ 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
@@ -33,8 +34,9 @@ The dashboard makes it possible to:
- 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/)
@@ -43,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
@@ -54,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
@@ -68,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
```
@@ -84,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`

View File

@@ -1,9 +1,9 @@
[
{
"tag": "New",
"text": "Custom DNS Zones for Private Network Resolution",
"link": "https://netbird.io/knowledge-hub/custom-dns-zones",
"linkText": "Read Release Article",
"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,

4538
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,34 +16,34 @@
"cypress:open": "cypress open"
},
"dependencies": {
"@axa-fr/react-oidc": "^7.22.18",
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@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",
"@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",
@@ -49,8 +52,9 @@
"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",
@@ -58,40 +62,42 @@
"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.23",
"lucide-react": "^0.539.0",
"next": "^14.2.35",
"lucide-react": "^0.566.0",
"next": "16.1.7",
"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",
"timescape": "^0.7.1",
"typescript": "^5"
},
"overrides": {
"minimatch": ">=10.2.1"
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@types/chroma-js": "^3.1.1",
"@types/js-cookie": "^3.0.6",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.5",
"eslint-config-next": "^16.1.6",
"postcss": "^8",
"prettier": "3.0.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

@@ -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,80 @@
"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(),
sort_by: "timestamp",
sort_order: "desc",
}),
[],
);
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

@@ -61,7 +61,7 @@ export default function NetworkRoutes() {
in our documentation.
</Paragraph>
<Callout className={"max-w-xl mt-3"} variant={"warning"}>
<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.{" "}

View File

@@ -12,14 +12,13 @@ import {
} 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,
@@ -28,19 +27,29 @@ import {
Trash2,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
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 { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
import {
NetworkProvider,
useNetworksContext,
} from "@/modules/networks/NetworkProvider";
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
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";
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
export default function NetworkDetailPage() {
const queryParameter = useSearchParams();
@@ -53,17 +62,34 @@ export default function NetworkDetailPage() {
useRedirect("/networks", false, !networkId);
return network && !isLoading ? (
<NetworkOverview network={network} />
<ReverseProxiesProvider initialNetwork={network}>
<NetworkOverview network={network} />
</ReverseProxiesProvider>
) : (
<FullScreenLoading />
<SkeletonNetwork />
);
}
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
@@ -71,56 +97,103 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
return (
<PageContainer>
<NetworkProvider network={network}>
<div className={"p-default py-6 mb-4"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
disabled={!permission.networks.read}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/network"}
label={network.name}
active={true}
/>
</Breadcrumbs>
<NetworkAccessControlProvider>
<NetworkProvider network={network}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
disabled={!permission.networks.read}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/network"}
label={network.name}
active={true}
/>
</Breadcrumbs>
<div className={"flex justify-between max-w-6xl"}>
<div
className={"w-full lg:w-1/2 flex justify-between items-center"}
>
<div className={"flex justify-between max-w-6xl"}>
<div
className={cn(
"flex items-center w-full",
!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}
/>
</div>
<NetworkProvider 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>
<NetworkActions />
</NetworkProvider>
</div>
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
</div>
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
</div>
</div>
<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>
<Separator />
<ResourcesSection network={network} />
<div className={"h-3"} />
<Separator />
<NetworkRoutingPeersSection network={network} />
</NetworkProvider>
<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>
</NetworkAccessControlProvider>
</PageContainer>
);
}

View File

@@ -26,6 +26,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { singularize } from "@utils/helpers";
import dayjs from "dayjs";
import { isEmpty, trim } from "lodash";
import {
@@ -36,13 +37,14 @@ import {
FlagIcon,
Globe,
History,
ListIcon,
MapPin,
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
RadioTowerIcon,
TimerResetIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { toASCII } from "punycode";
import React, { useMemo, useState } from "react";
@@ -52,21 +54,27 @@ 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 { 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 Link from "next/link";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
export default function PeerPage() {
@@ -99,10 +107,12 @@ export default function PeerPage() {
/>
);
return peer && !isLoading ? (
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
<PeerOverview key={peer?.id} />
</PeerProvider>
return peer && peer.id && !isLoading ? (
<ReverseProxiesProvider initialPeer={peer}>
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
<PeerOverview key={peer?.id} />
</PeerProvider>
</ReverseProxiesProvider>
) : (
<FullScreenLoading />
);
@@ -114,38 +124,60 @@ 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, update } = usePeer();
const { peer, peerGroups, update } = usePeer();
const { permission } = usePermissions();
const [name, setName] = useState(peer.name);
const [showEditNameModal, setShowEditNameModal] = useState(false);
const [tab, setTab] = useState("overview");
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({
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([
selectedGroups,
]);
@@ -175,7 +207,31 @@ const PeerGeneralInformation = () => {
});
};
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 (
<>
@@ -236,49 +292,145 @@ const PeerGeneralInformation = () => {
</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>
{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>
</>
);
};
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 className={"flex flex-col gap-8 lg:w-1/2 transition-all"}>
<PeerExpirationSettings />
<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>
{permission.groups.read && (
<div>
<Label>Assigned Groups</Label>
@@ -294,67 +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>
)}
{peer?.id && permission.peers.delete && (
<TabsTrigger value={"peer-job"}>
<RadioTowerIcon size={16} />
Remote Jobs
</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>
)}
{peer.id && permission.peers.delete && (
<TabsContent value={"peer-job"} className={"pb-8"}>
<PeerRemoteJobsSection peerID={peer.id} />
</TabsContent>
)}
</Tabs>
</div>
);
};
@@ -541,9 +647,9 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
peer.connected
? "just now"
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(peer.last_seen) +
")"
" (" +
dayjs().to(peer.last_seen) +
")"
}
/>

View File

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

@@ -56,7 +56,7 @@ export default function NetBirdSettings() {
Authentication
</VerticalTabs.Trigger>
{account?.settings?.embedded_idp_enabled &&
permission.identity_providers.read && (
permission?.identity_providers?.read && (
<VerticalTabs.Trigger value="identity-providers">
<FingerprintIcon size={14} />
Identity Providers

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

@@ -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;
@@ -169,6 +176,25 @@ p {
@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{

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,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,19 @@
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)}
fill={"currentColor"}
>
<path
fill={"currentColor"}
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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -6,9 +6,8 @@ import {
OidcProvider,
} from "@axa-fr/react-oidc";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useLocalStorage } from "@hooks/useLocalStorage";
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";
@@ -43,33 +42,6 @@ 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 {

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

@@ -2,6 +2,7 @@
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { cn } from "@utils/helpers";
import { motion } from "framer-motion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
@@ -23,7 +24,7 @@ const AccordionTrigger = React.forwardRef<
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center gap-4 font-medium transition-all [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
"flex flex-1 items-center gap-4 font-medium [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
className,
)}
{...props}
@@ -36,20 +37,41 @@ const AccordionTrigger = React.forwardRef<
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
{...props}
>
<div className=" pt-0">{children}</div>
</AccordionPrimitive.Content>
));
>(({ className, children }, ref) => {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
React.useEffect(() => {
const el = wrapperRef.current?.closest("[data-state]");
if (!el) return;
const update = () => setIsOpen(el.getAttribute("data-state") === "open");
update();
const observer = new MutationObserver(update);
observer.observe(el, { attributes: true, attributeFilter: ["data-state"] });
return () => observer.disconnect();
}, []);
return (
<div ref={wrapperRef}>
<motion.div
ref={ref}
initial={false}
animate={{
height: isOpen ? "auto" : 0,
opacity: isOpen ? 1 : 0,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
className={cn("overflow-hidden text-sm", className)}
>
<div className="pt-0">{children}</div>
</motion.div>
</div>
);
});
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

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

@@ -23,6 +23,7 @@ const variants = cva("", {
purple: ["bg-purple-950/50 border-purple-500 border text-purple-500"],
yellow: ["bg-yellow-950 border-yellow-500 border text-yellow-400"],
gray: ["bg-nb-gray-930/60 border-nb-gray-800/40 text-nb-gray-300 border"],
lightGray: ["bg-nb-gray-910 text-nb-gray-200 border border-nb-gray-900"],
grayer: [
"bg-nb-gray-900/40 border-nb-gray-800/40 text-nb-gray-300 border",
],
@@ -45,6 +46,7 @@ const variants = cva("", {
"blue-darker": ["hover:bg-sky-800"],
red: ["hover:bg-red-950/40"],
gray: ["hover:bg-nb-gray-900"],
lightGray: ["hover:bg-nb-gray-900"],
grayer: ["hover:bg-nb-gray-900"],
"gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"],
green: ["hover:bg-green-950/50"],

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

@@ -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,6 +73,9 @@ 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 rounded-sm",
],
"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",

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,95 @@
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>
{descriptionText && (
<span
className={
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
}
>
<TruncatedText text={descriptionText} maxWidth={"160px"} />
</span>
)}
</div>
</div>
);
};

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

@@ -8,7 +8,7 @@ import React from "react";
export const fancyToggleSwitchVariants = cva([], {
variants: {
variant: {
default: ["px-6 py-4 border rounded-md"],
default: ["px-5 py-4 border rounded-md"],
blank: null,
},
state: {
@@ -45,6 +45,8 @@ interface Props extends FancyToggleSwitchVariants {
disabled?: boolean;
dataCy?: string;
className?: string;
labelClassName?: string;
textWrapperClassName?: string;
}
export default function FancyToggleSwitch({
@@ -57,6 +59,8 @@ export default function FancyToggleSwitch({
dataCy,
className,
variant = "default",
labelClassName,
textWrapperClassName = "max-w-sm",
}: Readonly<Props>) {
const handleToggle = () => {
if (disabled) return;
@@ -87,8 +91,8 @@ export default function FancyToggleSwitch({
)}
>
<div className={"flex justify-between gap-10"}>
<div className={"max-w-sm"}>
<Label>{label}</Label>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>{label}</Label>
<HelpText margin={false}>{helpText}</HelpText>
</div>
<div className={"mt-2 pr-1"}>
@@ -99,7 +103,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,69 @@
import * as React from "react";
import FullTooltip from "@components/FullTooltip";
import { HelpCircle } from "lucide-react";
import { cn } from "@utils/helpers";
import { TooltipVariants } from "@components/Tooltip";
type Props = {
content: React.ReactNode;
children?: React.ReactNode;
interactive?: boolean;
className?: string;
triggerClassName?: string;
align?: "start" | "center" | "end";
side?: "top" | "right" | "bottom" | "left";
alignOffset?: number;
sideOffset?: number;
iconSize?: number;
delayDuration?: number;
} & TooltipVariants;
export const HelpTooltip = ({
content,
children,
interactive = false,
className,
variant = "default",
triggerClassName,
align = "start",
side = "top",
alignOffset = 0,
sideOffset,
iconSize = 12,
delayDuration = 300,
}: Props) => {
return (
<>
<FullTooltip
interactive={interactive}
side={side}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
delayDuration={delayDuration}
variant={variant}
className={
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
}
content={
<div className={cn("max-w-xs text-xs", className)}>{content}</div>
}
>
{children ? (
children
) : (
<span
className={cn(
"p-2 -m-2 inline-flex items-center justify-center relative top-[1px] group/help",
triggerClassName,
)}
>
<HelpCircle
size={iconSize}
className={"text-nb-gray-300 group-hover/help:text-nb-gray-100"}
/>
</span>
)}
</FullTooltip>
</>
);
};

View File

@@ -127,6 +127,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
suffix && "!pr-16",
icon && "!pl-10",
"border",
props.readOnly &&
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
className,
)}
/>

View File

@@ -9,17 +9,34 @@ const labelVariants = cva(
"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;
@@ -18,38 +18,95 @@ export interface NotifyProps<T> {
icon?: React.ReactNode;
backgroundColor?: string;
preventSuccessToast?: boolean;
showOnlyError?: boolean;
errorMessages?: ErrorResponse[];
}
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,
duration = 3500,
preventSuccessToast = false,
showOnlyError = false,
errorMessages,
}: 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 +114,11 @@ export default function Notification<T>({
promise
.then(() => {
setLoading(false);
closeToast();
if (preventSuccessToast) setPreventSuccess(true);
if (showOnlyError || preventSuccessToast) {
toast.dismiss(toastId);
} else {
setReadyToDismiss(true);
}
})
.catch((e) => {
const err = e as ErrorResponse;
@@ -78,78 +138,79 @@ 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>
const hideUntilError = showOnlyError && loading && !error;
if (hideUntilError) return null;
<button
className="flex dark:border-nb-gray-900 items-center cursor-pointer group"
onClick={() => toast.dismiss(t.id)}
return (
<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

@@ -30,6 +30,7 @@ import {
MonitorSmartphoneIcon,
NetworkIcon,
SearchIcon,
ShieldCheck,
WorkflowIcon,
} from "lucide-react";
import * as React from "react";
@@ -40,10 +41,13 @@ import { useElementSize } from "@/hooks/useElementSize";
import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import type { Peer } from "@/interfaces/Peer";
import { PolicyRuleResource } from "@/interfaces/Policy";
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
import { User } from "@/interfaces/User";
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 +72,21 @@ interface MultiSelectProps {
showResourceCounter?: boolean;
showResources?: boolean;
showPeers?: boolean;
showPeerCounter?: 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[];
additionalResources?: NetworkResource[];
policies?: Policy[];
}
export function PeerGroupSelector({
onChange,
@@ -94,6 +105,10 @@ export function PeerGroupSelector({
showResourceCounter = true,
showResources = false,
showPeers = false,
showPeerCounter = true,
hideGroupsTab = false,
tabOrder,
closeOnSelect = false,
resource,
onResourceChange,
placeholder = "Add or select group(s)...",
@@ -102,11 +117,22 @@ export function PeerGroupSelector({
side = "bottom",
users,
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
resourceIds,
additionalResources,
policies,
}: Readonly<MultiSelectProps>) {
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const resources = useMemo(() => {
if (!additionalResources?.length) return fetchedResources;
const additional = additionalResources.filter(
(ar) => !fetchedResources?.some((r) => r.id === ar.id),
);
return [...(fetchedResources || []), ...additional];
}, [fetchedResources, additionalResources]);
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
@@ -229,7 +255,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) {
@@ -272,6 +304,9 @@ export function PeerGroupSelector({
: undefined,
);
onChange([]);
if (closeOnSelect) {
setOpen(false);
}
};
const selectPeer = (peer?: Peer) => {
@@ -281,6 +316,9 @@ export function PeerGroupSelector({
type: "peer",
});
onChange([]);
if (closeOnSelect) {
setOpen(false);
}
};
return (
@@ -306,7 +344,7 @@ export function PeerGroupSelector({
"min-h-[46px] w-full relative items-center group",
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
"disabled:pointer-events-none disabled:opacity-30 transition-all",
"disabled:pointer-events-none disabled:opacity-60 transition-all",
)}
disabled={disabled}
data-cy={dataCy}
@@ -320,7 +358,14 @@ export function PeerGroupSelector({
{resource && (
<ResourceBadge
className={"py-[3px]"}
resource={resources?.find((r) => r.id === resource.id)}
resource={
resources?.find((r) => r.id === resource.id) ??
({
id: resource.id,
name: resource.id,
type: resource.type,
} as NetworkResource)
}
peer={peers?.find((p) => p.id === resource.id)}
onClick={(e) => {
e.preventDefault();
@@ -374,7 +419,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>
@@ -438,11 +485,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>
@@ -535,12 +591,21 @@ export function PeerGroupSelector({
<ResourcesCounter group={option} />
)}
{policies && (
<PolicyCounter
group={option}
policies={policies}
/>
)}
<div className={"flex gap-4 items-center"}>
{!users ? (
<PeerCounter
group={option}
showResourceCounter={showResourceCounter}
/>
showPeerCounter && (
<PeerCounter
group={option}
showResourceCounter={showResourceCounter}
/>
)
) : (
<UsersCounter
group={option}
@@ -562,7 +627,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 +661,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>
);
};
@@ -723,6 +821,39 @@ const ResourcesCounter = ({ group }: { group: Group }) => {
) : null;
};
const PolicyCounter = ({
group,
policies,
}: {
group: Group;
policies: Policy[];
}) => {
const count = useMemo(() => {
if (!group.id) return 0;
return policies.filter((policy) => {
const destinations = policy.rules?.[0]?.destinations as
| (Group | string)[]
| undefined;
return destinations?.some((d) =>
typeof d === "string" ? d === group.id : d.id === group.id,
);
}).length;
}, [group.id, policies]);
if (count === 0) return null;
return (
<div
className={
"text-nb-gray-300 font-medium flex items-center gap-2 transition-all"
}
>
<ShieldCheck size={14} className={"shrink-0"} />
{count} {count === 1 ? "Policy" : "Policies"}
</div>
);
};
const resourcesSearchPredicate = (item: NetworkResource, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
@@ -787,6 +918,7 @@ const ResourcesList = ({
<VirtualScrollAreaList
items={filteredItems}
onSelect={onChange}
estimatedItemHeight={42}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => {
return (
@@ -896,6 +1028,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;
@@ -904,7 +1037,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(
@@ -915,7 +1048,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

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

@@ -8,6 +8,7 @@ type Props = {
description: ReactNode;
icon?: ReactNode;
className?: string;
disabled?: boolean;
};
export const RadioCard = ({
@@ -16,15 +17,18 @@ export const RadioCard = ({
description,
className,
icon,
disabled,
}: Props) => {
return (
<RadioGroup.Item
value={value}
disabled={disabled}
className={cn(
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
"hover:bg-nb-gray-930",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-nb-gray-930/60",
className,
)}
>

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

@@ -75,23 +75,59 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
extra?: React.ReactNode;
icon?: React.ReactNode;
description?: React.ReactNode;
}
>(({ className, children, extra, icon, description, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
"relative flex w-full select-none items-center rounded-md py-1.5 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
icon ? "pl-2 pr-8" : "pl-8 pr-2",
description && "py-2",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{icon ? (
<>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex items-center gap-2">
<span className="flex-shrink-0">{icon}</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && (
<span className="text-xs text-nb-gray-300 font-normal">
{description}
</span>
)}
</div>
</div>
</>
) : (
<>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && (
<span className="text-xs text-nb-gray-300 font-normal">
{description}
</span>
)}
</div>
</>
)}
{extra}
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;

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

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

@@ -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";
@@ -37,6 +38,7 @@ const ModalOverlay = React.forwardRef<
"bg-black/30 dark:bg-black/40 backdrop-blur-sm",
className,
)}
style={{ scrollbarGutter: "stable both-edges" }}
{...props}
/>
));
@@ -58,6 +60,7 @@ const ModalContent = React.forwardRef<
children,
showClose = true,
maxWidthClass = "max-w-3xl",
onPointerDownOutside,
...props
},
ref,
@@ -71,21 +74,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 +146,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>

View File

@@ -14,6 +14,7 @@ import * as React from "react";
import { useEffect, useRef, useState } from "react";
import Skeleton from "react-loading-skeleton";
import { useElementSize } from "@/hooks/useElementSize";
import { DropdownInfoText } from "@components/DropdownInfoText";
export interface SelectOption {
label: string | React.ReactNode;
@@ -25,13 +26,16 @@ export interface SelectOption {
}>;
renderItem?: () => React.ReactNode;
searchValue?: string;
className?: string;
disabled?: boolean;
}
interface SelectDropdownProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
popoverWidth?: "auto" | number;
popoverWidth?: "auto" | "content" | number;
popoverMinWidth?: number;
options: SelectOption[];
showSearch?: boolean;
showValues?: boolean;
@@ -44,6 +48,9 @@ interface SelectDropdownProps {
children?: React.ReactNode;
maxHeight?: number;
triggerClassName?: string;
iconSize?: number;
truncate?: boolean;
compact?: boolean;
}
export function SelectDropdown({
@@ -51,6 +58,7 @@ export function SelectDropdown({
value,
disabled = false,
popoverWidth = "auto",
popoverMinWidth,
options,
showSearch = false,
showValues = false,
@@ -63,6 +71,9 @@ export function SelectDropdown({
children,
maxHeight,
triggerClassName,
iconSize = 14,
truncate = false,
compact = false,
}: Readonly<SelectDropdownProps>) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -102,15 +113,18 @@ export function SelectDropdown({
const SelectedItem = () => {
return (
<div className={"flex items-center gap-2.5"}>
{selected?.icon && <selected.icon size={14} width={14} />}
<div className={cn("flex items-center gap-2.5", truncate && "min-w-0")}>
{selected?.icon && <selected.icon size={iconSize} width={iconSize} />}
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
truncate && "min-w-0",
)}
>
<span className={"text-nb-gray-200"}>{selected?.label}</span>
<span className={cn("text-nb-gray-200", truncate && "truncate")}>
{selected?.label}
</span>
</div>
</div>
);
@@ -169,9 +183,18 @@ export function SelectDropdown({
)}
</PopoverTrigger>
<PopoverContent
className="w-full p-0 shadow-sm shadow-nb-gray-950 focus:outline-none"
className={cn(
"p-0 shadow-sm shadow-nb-gray-950 focus:outline-none",
popoverWidth !== "content" && "w-full",
)}
style={{
width: popoverWidth === "auto" ? width : popoverWidth,
width:
popoverWidth === "content"
? "auto"
: popoverWidth === "auto"
? width
: popoverWidth,
minWidth: popoverMinWidth,
}}
align="start"
side={"bottom"}
@@ -194,27 +217,30 @@ export function SelectDropdown({
)}
{filteredItems.length == 0 && (
<div className={"text-center pb-2 px-3 text-nb-gray-400 text-xs"}>
There are no results matching your search.
</div>
<DropdownInfoText className={"max-w-sm mx-auto px-4"}>
There are no results matching your search. Please try a
different search term.
</DropdownInfoText>
)}
<ScrollArea
className={cn(
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
!showSearch && "pt-2",
"overflow-y-auto flex flex-col gap-1",
compact ? "pl-1 pr-1" : "pl-2 pr-3",
!showSearch && (compact ? "pt-1" : "pt-2"),
)}
style={{
maxHeight: maxHeight ?? 380,
}}
>
<CommandGroup>
<div className={"grid grid-cols-1 gap-1 pb-2"}>
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
{filteredItems.map((option) => (
<SelectDropdownItem
option={option}
toggle={toggle}
key={option.value}
iconSize={iconSize}
showValue={showValues}
size={size}
/>
@@ -234,11 +260,13 @@ const SelectDropdownItem = ({
toggle,
showValue = false,
size = "sm",
iconSize = 14,
}: {
option: SelectOption;
toggle: (value: string) => void;
showValue?: boolean;
size: "xs" | "sm";
iconSize?: number;
}) => {
const value = option.value || "" + option.label || "";
const elementRef = useRef<HTMLDivElement>(null);
@@ -253,22 +281,34 @@ const SelectDropdownItem = ({
}, [isVisible]);
return (
<div ref={elementRef} className={"transition-all"}>
<div ref={elementRef} className={"transition-all w-full"}>
{visible ? (
<CommandItem
value={option?.searchValue ?? value}
ref={elementRef}
className={"py-1 px-2"}
onSelect={() => toggle(option.value)}
className={"py-1 px-2 w-full"}
onSelect={() => !option?.disabled && toggle(option.value)}
onClick={(e) => e.preventDefault()}
disabled={option?.disabled}
>
<div className={"flex items-center gap-2.5 p-1"}>
{option.icon && <option.icon size={14} width={14} />}
<div
className={cn(
"flex items-center gap-2.5 p-1 w-full",
option?.className,
option?.disabled && "cursor-not-allowed",
)}
>
{option.icon && (
<div className={"shrink-0"}>
<option.icon size={iconSize} width={iconSize} />
</div>
)}
{option?.renderItem && option.renderItem()}
{!option?.renderItem && (
<div
className={cn(
"flex flex-col text-sm font-medium",
"flex flex-col text-sm font-medium w-full",
size === "xs" && "text-xs",
)}
>

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import Skeleton from "react-loading-skeleton";
import { cn } from "@utils/helpers";
type Props = {
className?: string;
};
export const SkeletonDeviceCard = ({ className = "min-h-[59px]" }: Props) => {
return (
<div
className={cn("py-2 pr-4 pl-2 flex gap-3 relative -left-2", className)}
>
<Skeleton height={36} width={36} />
<div className={"flex flex-col pr-[1.15rem]"}>
<Skeleton height={16} width={70} />
<Skeleton height={16} width={140} />
</div>
</div>
);
};

View File

@@ -0,0 +1,42 @@
import * as React from "react";
import { useEffect, useState } from "react";
import Skeleton from "react-loading-skeleton";
import SkeletonTable from "@components/skeletons/SkeletonTable";
export const SkeletonNetwork = ({ delay = 400 }: { delay?: number }) => {
const [show, setShow] = useState(delay === 0);
useEffect(() => {
if (delay === 0) return;
const timer = setTimeout(() => setShow(true), delay);
return () => clearTimeout(timer);
}, [delay]);
if (!show) return null;
return (
<div className={"p-default py-6 w-full"}>
<Skeleton height={24} width={240} className={"mb-4"} />
<div className={"mb-8 flex items-center gap-4"}>
<Skeleton height={48} width={48} />
<Skeleton height={20} width={200} />
</div>
<div className={"mb-4"}>
<Skeleton height={106} className={"mb-2 w-full max-w-[574px]"} />
</div>
<div className={"flex items-center gap-4 mb-8"}>
<Skeleton height={24} width={130} />
<Skeleton height={24} width={130} />
<Skeleton height={24} width={130} />
</div>
<div>
<Skeleton height={16} width={530} className={"w-full max-w-[530px]"} />
<Skeleton height={16} width={430} className={"w-full max-w-[430px]"} />
</div>
<div className={"w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import Skeleton from "react-loading-skeleton";
export const SkeletonSettings = () => {
return (
<div className={"p-default py-6 max-w-2xl"}>
<Skeleton height={24} width={200} className={"mb-6"} />
<Skeleton height={32} width={110} className={"mb-10"} />
<div className={"mb-8"}>
<Skeleton height={17} width={200} className={"mb-2"} />
<Skeleton height={80} width={"100%"} />
</div>
<div className={"mb-8"}>
<Skeleton height={17} width={200} className={"mb-2"} />
<Skeleton height={80} width={"100%"} />
</div>
<Skeleton height={80} width={"100%"} />
</div>
);
};

View File

@@ -14,11 +14,6 @@ import {
TableWrapper,
} from "@components/table/Table";
import NoResults from "@components/ui/NoResults";
import {
Accordion,
AccordionContent,
AccordionItem,
} from "@radix-ui/react-accordion";
import { RankingInfo } from "@tanstack/match-sorter-utils";
import {
ColumnDef,
@@ -58,6 +53,7 @@ declare module "@tanstack/table-core" {
}
interface SortingFns {
checkbox: SortingFn<unknown>;
datetime: SortingFn<unknown>;
}
}
@@ -104,6 +100,15 @@ const arrIncludesSomeExact: FilterFn<any> = (
return value.some((val) => val === rowValue);
};
const datetimeSort: SortingFn<any> = (rowA, rowB, columnId) => {
const aConnected = rowA.original?.connected;
const bConnected = rowB.original?.connected;
if (aConnected !== bConnected) return aConnected ? 1 : -1;
const a = dayjs(rowA.getValue(columnId)).valueOf();
const b = dayjs(rowB.getValue(columnId)).valueOf();
return a - b;
};
const checkboxSort: SortingFn<any> = (rowA, rowB, columnId) => {
const valueA =
columnId === "select" ? rowA.getIsSelected() : rowA.getValue(columnId);
@@ -138,9 +143,10 @@ interface DataTableProps<TData, TValue> {
className?: string;
inset?: boolean;
isLoading?: boolean;
isFetching?: boolean;
as?: "div" | "table";
paginationClassName?: string;
rowClassName?: string;
rowClassName?: string | ((row: Row<TData>) => string);
wrapperClassName?: string;
tableClassName?: string;
searchClassName?: string;
@@ -155,6 +161,8 @@ interface DataTableProps<TData, TValue> {
useRowId?: boolean;
headingTarget?: HTMLHeadingElement | null;
showResetFilterButton?: boolean;
serverSidePagination?: boolean;
hasServerSideFilters?: boolean;
onFilterReset?: () => void;
wrapperComponent?: React.ElementType;
wrapperProps?: any;
@@ -200,6 +208,7 @@ export function DataTable<TData, TValue>({
tableClassName,
inset,
isLoading = false,
isFetching = false,
paginationClassName,
rowClassName,
wrapperClassName,
@@ -216,6 +225,8 @@ export function DataTable<TData, TValue>({
useRowId,
headingTarget,
showResetFilterButton = true,
serverSidePagination = false,
hasServerSideFilters,
onFilterReset,
showSearchAndFilters = true,
wrapperProps,
@@ -241,6 +252,19 @@ export function DataTable<TData, TValue>({
const path = usePathname();
const isInitialRender = useRef(true);
const [showOverlay, setShowOverlay] = useState(false);
const overlayTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (!serverSidePagination) return;
if (isFetching && !isLoading) {
overlayTimer.current = setTimeout(() => setShowOverlay(true), 500);
} else {
clearTimeout(overlayTimer.current);
setShowOverlay(false);
}
return () => clearTimeout(overlayTimer.current);
}, [serverSidePagination, isFetching, isLoading]);
const [localColumnFilters, setLocalColumnFilters] =
useLocalStorage<ColumnFiltersState>(
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
@@ -282,6 +306,7 @@ export function DataTable<TData, TValue>({
autoResetAll: false,
autoResetExpanded: false,
manualPagination: manualPagination,
manualSorting: serverSidePagination,
manualFiltering: manualFiltering || manualColumnFiltering,
pageCount: pageCount,
state: {
@@ -309,6 +334,7 @@ export function DataTable<TData, TValue>({
},
sortingFns: {
checkbox: checkboxSort,
datetime: datetimeSort,
},
getRowId: useRowId ? (row) => row.id : undefined,
onRowSelectionChange: setRowSelection,
@@ -416,12 +442,7 @@ export function DataTable<TData, TValue>({
return (
<div className={cn("relative table-fixed-scroll", className)}>
{showSearchAndFilters && (
<div
className={cn(
"flex gap-x-4 gap-y-6 flex-wrap",
!minimal && "p-default",
)}
>
<div className={cn("flex gap-x-4 gap-y-6", !minimal && "p-default")}>
<DataTableGlobalSearch
className={searchClassName}
disabled={false} // Never disable the search input
@@ -444,10 +465,14 @@ export function DataTable<TData, TValue>({
/>
{children?.(table)}
{showResetFilterButton && (
<DataTableResetFilterButton onClick={resetFilters} table={table} />
<DataTableResetFilterButton
onClick={resetFilters}
table={table}
hasServerSideFilters={hasServerSideFilters}
/>
)}
<div className={"flex gap-4 flex-wrap grow"}>
<div className={"flex gap-4 flex-wrap"}></div>
<div className={"flex gap-4 grow"}>
<div className={"flex gap-4"}></div>
{rightSide?.(table)}
</div>
</div>
@@ -455,50 +480,48 @@ export function DataTable<TData, TValue>({
{aboveTable?.(table)}
<TableWrapper
wrapperComponent={wrapperComponent}
wrapperProps={wrapperProps}
>
{isLoading ? (
<TableContentSkeleton />
) : !hasInitialData ? (
getStartedCard
) : (
<TableComponent
className={cn("relative mt-6", tableClassName)}
minimal={minimal}
>
{showHeader && as == "table" && (
<TableHeaderComponent minimal={minimal}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRowComponent key={headerGroup.id} minimal={minimal}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
minimal={minimal}
inset={inset}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRowComponent>
))}
</TableHeaderComponent>
)}
<Accordion
asChild={true}
type={"multiple"}
value={accordion}
onValueChange={setAccordion}
<div className="relative">
{showOverlay && (
<div className="absolute inset-0 bg-nb-gray-950/60 z-10 rounded-md animate-pulse" />
)}
<TableWrapper
wrapperComponent={wrapperComponent}
wrapperProps={wrapperProps}
>
{isLoading ? (
<TableContentSkeleton />
) : !hasInitialData && !hasServerSideFilters ? (
getStartedCard
) : (
<TableComponent
className={cn("relative mt-6", tableClassName)}
minimal={minimal}
>
{showHeader && as == "table" && (
<TableHeaderComponent minimal={minimal}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRowComponent key={headerGroup.id} minimal={minimal}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
minimal={minimal}
inset={inset}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRowComponent>
))}
</TableHeaderComponent>
)}
<TableBodyComponent
className={cn(
"relative",
@@ -509,95 +532,83 @@ export function DataTable<TData, TValue>({
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const expandedRow = renderExpandedRow?.(row.original);
const rowId = row.original.id ?? row.id;
const isExpanded = accordion?.includes(rowId);
const rowContent = (
<AccordionItem
value={row.original.id}
asChild={true}
key={row.id}
>
<>
<React.Fragment key={row.id}>
<TableRowComponent
minimal={minimal}
data-row-id={rowId}
className={cn(
(onRowClick || renderExpandedRow) &&
"relative group/accordion",
(onRowClick || expandedRow) && "cursor-pointer",
typeof rowClassName === "function"
? rowClassName(row)
: rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
data-accordion={isExpanded ? "opened" : "closed"}
onClick={(e) => {
if (expandedRow) {
e.preventDefault();
e.stopPropagation();
setAccordion((prev) => {
if (prev?.includes(rowId)) {
return prev.filter((item) => item !== rowId);
} else {
return [...(prev ?? []), rowId];
}
});
}
}}
>
{row.getVisibleCells().map((cell) => (
<TableCellComponent
key={cell.id}
className={cn("relative", tableCellClassName)}
minimal={minimal}
inset={inset}
onClick={() => {
onRowClick && onRowClick(row, cell.column.id);
}}
>
<div
className={
"absolute left-0 top-0 w-full h-full z-0"
}
></div>
<div className={"relative z-[1]"}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
</TableCellComponent>
))}
</TableRowComponent>
{expandedRow && isExpanded && (
<TableRowComponent
data-row-id={row.id + "-expanded-row"}
minimal={minimal}
data-row-id={row.original.id}
className={cn(
(onRowClick || renderExpandedRow) &&
"relative group/accordion",
(onRowClick || expandedRow) && "cursor-pointer",
rowClassName,
onRowClick && "cursor-pointer relative",
typeof rowClassName === "function"
? rowClassName(row)
: rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
data-accordion={
accordion?.includes(row.original.id)
? "opened"
: "closed"
}
onClick={(e) => {
if (expandedRow) {
e.preventDefault();
e.stopPropagation();
setAccordion((prev) => {
if (prev?.includes(row.original.id)) {
return prev.filter(
(item) => item !== row.original.id,
);
} else {
return [...(prev ?? []), row.original.id];
}
});
}
}}
>
<>
{row.getVisibleCells().map((cell) => (
<TableCellComponent
key={cell.id}
className={cn("relative", tableCellClassName)}
minimal={minimal}
inset={inset}
onClick={() => {
onRowClick &&
onRowClick(row, cell.column.id);
}}
>
<div
className={
"absolute left-0 top-0 w-full h-full z-0"
}
></div>
<div className={"relative z-[1]"}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
</TableCellComponent>
))}
</>
<TableDataUnstyledComponent
className={"w-full"}
colSpan={row.getVisibleCells().length}
>
{expandedRow}
</TableDataUnstyledComponent>
</TableRowComponent>
{expandedRow && (
<AccordionContent asChild={true}>
<TableRowComponent
data-row-id={row.id + "-expanded-row"}
key={row.id + "-expanded-row"}
minimal={minimal}
className={cn(
onRowClick && "cursor-pointer relative",
rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
>
<TableDataUnstyledComponent
className={"w-full"}
colSpan={row.getVisibleCells().length}
>
{expandedRow}
</TableDataUnstyledComponent>
</TableRowComponent>
</AccordionContent>
)}
</>
</AccordionItem>
)}
</React.Fragment>
);
return renderRow
@@ -615,10 +626,10 @@ export function DataTable<TData, TValue>({
</TableRowUnstyledComponent>
)}
</TableBodyComponent>
</Accordion>
</TableComponent>
)}
</TableWrapper>
</TableComponent>
)}
</TableWrapper>
</div>
<div className={paginationClassName}>
<DataTablePagination
@@ -629,7 +640,13 @@ export function DataTable<TData, TValue>({
/>
</div>
<DataTableHeadingPortal table={table} headingTarget={headingTarget} />
<DataTableHeadingPortal
table={table}
headingTarget={headingTarget}
totalRecords={totalRecords}
manualPagination={manualPagination}
hasActiveFilters={hasServerSideFilters}
/>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
import type { Column } from "@tanstack/table-core";
import { cn } from "@utils/helpers";
import React from "react";
import { useOptionalServerPagination } from "@/contexts/ServerPaginationProvider";
type Props = {
column: Column<any>;
@@ -12,6 +13,9 @@ type Props = {
tooltip?: string | React.ReactNode;
center?: boolean;
className?: string;
sorting?: boolean;
onSort?: () => void;
name?: string;
};
export default function DataTableHeader({
children,
@@ -19,23 +23,44 @@ export default function DataTableHeader({
tooltip,
center,
className,
sorting = true,
onSort,
name,
}: Props) {
const serverPagination = useOptionalServerPagination();
const handleSort = () => {
if (onSort) {
onSort();
} else {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
column.toggleSorting(direction === "desc");
}
if (name && serverPagination?.setSort) {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
serverPagination.setSort(name, direction);
}
};
return (
<FullTooltip content={tooltip} disabled={!tooltip}>
<div
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
onClick={sorting ? handleSort : undefined}
className={cn(
"flex items-center whitespace-nowrap cursor-pointer gap-2 dark:text-gray-400 dark:hover:text-gray-300 transition-all select-none hover:text-nb-gray text-xs tracking-wide",
"flex items-center whitespace-nowrap gap-2 dark:text-gray-400 transition-all select-none text-xs tracking-wide",
sorting &&
"cursor-pointer dark:hover:text-gray-300 hover:text-nb-gray",
center && "justify-center w-full",
className,
)}
>
{children}
{column.getIsSorted() === "desc" ? (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
)}
{sorting &&
(column.getIsSorted() === "desc" ? (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
))}
</div>
</FullTooltip>
);

View File

@@ -6,27 +6,57 @@ import { createPortal } from "react-dom";
type Props<TData> = {
table: Table<TData> | null;
headingTarget?: HTMLHeadingElement | null;
totalRecords?: number;
manualPagination?: boolean;
hasActiveFilters?: boolean;
};
export const DataTableHeadingPortal = function <TData>({
table,
headingTarget,
totalRecords,
manualPagination,
hasActiveFilters,
}: Props<TData>) {
const hasMounted = useRef(false);
const initialTotalRecords = useRef<number | undefined>(undefined);
if (
manualPagination &&
totalRecords !== undefined &&
initialTotalRecords.current === undefined
) {
initialTotalRecords.current = totalRecords;
}
if (!headingTarget) return;
if (!hasMounted.current) hasMounted.current = true;
const totalItems = table?.getPreFilteredRowModel().rows.length;
const filteredItems = table?.getFilteredRowModel().rows.length;
const filteredItems = manualPagination
? totalRecords
: table?.getFilteredRowModel().rows.length;
const getTotalRecords = () => {
if (Number(initialTotalRecords.current) < Number(filteredItems)) {
initialTotalRecords.current = filteredItems;
return filteredItems;
}
return initialTotalRecords.current;
};
const totalItems = manualPagination
? getTotalRecords()
: table?.getPreFilteredRowModel().rows.length;
if (!totalItems || totalItems == 1) return;
const hasAnyFiltersActive =
table &&
!(
table?.getState().columnFilters.length <= 0 &&
table?.getState().globalFilter === ""
);
const hasAnyFiltersActive = manualPagination
? hasActiveFilters ?? totalRecords !== initialTotalRecords.current
: table &&
!(
table?.getState().columnFilters.length <= 0 &&
table?.getState().globalFilter === ""
);
const portalContainer = document.createElement("span");
headingTarget.prepend(portalContainer);

View File

@@ -1,21 +0,0 @@
import { Sparkles } from "lucide-react";
import * as React from "react";
export const AIButton = () => {
return (
<div
className={
"animated-gradient-bg gap-2 flex items-center justify-center text-sm font-medium p-[2px] rounded-md group"
}
>
<div
className={
"flex items-center justify-center w-full h-full gap-2 bg-nb-gray-930/70 px-3 py-2.5 rounded-md"
}
>
<Sparkles size={16} />
AI Rule Wizard
</div>
</div>
);
};

View File

@@ -64,8 +64,16 @@ const Time = ({
}
}, [value]);
const { ref, ...rootProps } = getRootProps();
return (
<div className={"timescape w-full"} {...getRootProps()}>
<div
className={"timescape w-full"}
ref={(element) => {
ref(element);
}}
{...rootProps}
>
<div>
<input {...getInputProps("years")} />
<span className={"separator"}>/</span>

View File

@@ -19,40 +19,46 @@ function Calendar({
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
months: "flex flex-col sm:flex-row space-y-4 sm:space-y-0 relative",
month: "space-y-4 pr-4 last:pr-0",
month_caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
button_previous: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-0 top-0 z-10",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
button_next: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-0 top-0 z-10",
),
month_grid: "w-full border-collapse space-y-1",
weekdays: "flex",
weekday:
"text-neutral-500 rounded-md w-9 font-normal text-[0.8rem] dark:text-neutral-400",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-neutral-100/50 [&:has([aria-selected])]:bg-neutral-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20 dark:[&:has([aria-selected].day-outside)]:bg-neutral-800/50 dark:[&:has([aria-selected])]:bg-neutral-800",
day: cn("h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end rounded-r-md",
day_range_start: "day-range-start rounded-l-md",
day_selected:
week: "flex w-full mt-2",
day: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-neutral-100/50 [&:has([aria-selected])]:bg-neutral-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20 dark:[&:has([aria-selected].day-outside)]:bg-neutral-800/50 dark:[&:has([aria-selected])]:bg-neutral-800",
day_button: cn("h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
range_end: "day-range-end rounded-r-md",
range_start: "day-range-start rounded-l-md",
selected:
"bg-neutral-900 text-neutral-50 hover:bg-neutral-900 hover:text-neutral-50 focus:bg-neutral-900 focus:text-neutral-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50 dark:hover:text-neutral-900 dark:focus:bg-neutral-50 dark:focus:text-neutral-900",
day_today: "text-neutral-900 dark:text-red-500",
day_outside:
today: "text-neutral-900 dark:text-red-500",
outside:
"day-outside text-neutral-500 opacity-50 aria-selected:bg-neutral-100/50 aria-selected:text-neutral-500 aria-selected:opacity-30 dark:text-neutral-400 dark:aria-selected:bg-neutral-800/50 dark:aria-selected:text-neutral-400",
day_disabled: "text-neutral-500 opacity-50 dark:text-neutral-400",
day_range_middle:
disabled: "text-neutral-500 opacity-50 dark:text-neutral-400",
range_middle:
"aria-selected:bg-neutral-100 aria-selected:text-neutral-900 dark:aria-selected:bg-nb-gray-800 dark:aria-selected:text-neutral-50 rounded-none",
day_hidden: "invisible",
hidden: "invisible",
...classNames,
}}
components={{
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
IconRight: () => <ChevronRight className="h-4 w-4" />,
Chevron: ({ orientation }) =>
orientation === "left" ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
),
}}
{...props}
/>

View File

@@ -9,8 +9,11 @@ import { useCountries } from "@/contexts/CountryProvider";
type Props = {
value: string;
onChange: (value: string) => void;
iconSize?: number;
popoverWidth?: "auto" | "content" | number;
truncate?: boolean;
};
export const CountrySelector = ({ value, onChange }: Props) => {
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
const { countries, isLoading } = useCountries();
const countryList = useMemo(() => {
@@ -22,7 +25,7 @@ export const CountrySelector = ({ value, onChange }: Props) => {
}) =>
createElement(RoundedFlag, {
country: country.country_code,
size: 20,
size: iconSize,
...props,
});
return {
@@ -42,7 +45,10 @@ export const CountrySelector = ({ value, onChange }: Props) => {
searchPlaceholder={"Search country..."}
value={value}
onChange={onChange}
iconSize={iconSize}
options={countryList || []}
popoverWidth={popoverWidth}
truncate={truncate}
/>
</div>
);

View File

@@ -2,18 +2,17 @@ import { cn } from "@utils/helpers";
import LoadingIcon from "@/assets/icons/LoadingIcon";
type Props = {
height?: "screen" | "auto";
fullScreen?: boolean
};
export default function FullScreenLoading({ height = "screen" }: Props) {
export default function FullScreenLoading({ fullScreen = true }: Props) {
return (
<div
className={cn(
"flex items-center justify-center w-screen",
height == "screen" && "h-screen",
height == "auto" && "h-auto",
fullScreen && "h-screen",
)}
>
<LoadingIcon className={"fill-netbird"} size={44} />
<LoadingIcon className="fill-netbird" size={44} />
</div>
);
}

View File

@@ -64,7 +64,7 @@ export default function GetStartedTest({
{description}
</Paragraph>
</div>
<div>{button}</div>
{button && <div>{button}</div>}
</div>
</div>
<Paragraph className={"text-sm justify-center pb-5 px-8"}>

View File

@@ -26,6 +26,7 @@ type Props = {
showResources?: boolean;
redirectGroupTab?: string;
showUsers?: boolean;
disableRedirect?: boolean;
};
export default function MultipleGroups({
@@ -37,6 +38,7 @@ export default function MultipleGroups({
showResources = false,
showUsers = false,
redirectGroupTab,
disableRedirect = false,
}: Readonly<Props>) {
const { permission } = usePermissions();
@@ -64,6 +66,7 @@ export default function MultipleGroups({
{firstGroup && (
<GroupBadge
group={firstGroup}
showNewBadge={true}
className={
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
}
@@ -101,7 +104,7 @@ export default function MultipleGroups({
return (
group && (
<div
key={group.id}
key={group?.id || group?.name}
className={
"flex gap-2 items-center justify-between w-full"
}
@@ -110,16 +113,23 @@ export default function MultipleGroups({
group={group}
className={"py-0"}
textClassName={"py-1.5"}
redirectToGroupPage={true}
showNewBadge={true}
redirectToGroupPage={!disableRedirect}
redirectGroupTab={redirectGroupTab}
></GroupBadge>
<ArrowRightIcon size={14} />
{showResources ? (
<ResourceCountBadge group={group} />
<ResourceCountBadge
group={group}
disableRedirect={disableRedirect}
/>
) : showUsers ? (
<UserCountStack group={group} />
) : (
<PeerCountBadge group={group} />
<PeerCountBadge
group={group}
disableRedirect={disableRedirect}
/>
)}
</div>
)

View File

@@ -1,16 +0,0 @@
import * as React from "react";
type Props = {
text?: string;
};
export const NewBadge = ({ text = "NEW" }: Props) => {
return (
<span
className={
"text-[7px] relative top-[.25px] leading-[0] bg-green-900 border border-green-500/20 py-1.5 px-1 rounded-[3px] text-green-400"
}
>
{text}
</span>
);
};

View File

@@ -5,6 +5,7 @@ import { FilterX } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useCallback } from "react";
import Skeleton from "react-loading-skeleton";
import SquareIcon from "@components/SquareIcon";
type Props = {
icon?: React.ReactNode;
@@ -54,7 +55,7 @@ export default function NoResults({
<div className={cn("relative overflow-hidden", className)}>
<div
className={
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/70 w-full h-full overflow-hidden top-0"
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/50 w-full h-full overflow-hidden top-0"
}
></div>
<div
@@ -66,18 +67,21 @@ export default function NoResults({
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
</div>
</div>
<div
className={cn("max-w-md mx-auto relative z-20 py-6", contentClassName)}
>
<div
className={
"mx-auto w-14 h-14 bg-nb-gray-930 flex items-center justify-center mb-3 rounded-md"
}
>
{icon ? icon : <FilterX size={24} />}
<div className={"flex items-center justify-center mb-6"}>
<SquareIcon
icon={icon ? icon : <FilterX size={24} />}
color={"gray"}
size={"large"}
/>
</div>
<div className={"text-center"}>
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1>
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}>

View File

@@ -1,84 +0,0 @@
import Badge, { BadgeVariants } from "@components/Badge";
import { cn } from "@utils/helpers";
import { EyeIcon, MonitorSmartphoneIcon, SquarePen } from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import { Group } from "@/interfaces/Group";
import { AssignPeerToGroupModal } from "@/modules/groups/AssignPeerToGroupModal";
type Props = {
children?: React.ReactNode;
group?: Group;
useSave?: boolean;
onAssignmentChange?: (group: Group) => void;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
export default function PeerBadge({
children,
group,
variant = "gray",
className,
useSave = true,
onAssignmentChange,
}: Props) {
const [editGroupPeersModal, setEditGroupPeersModal] = useState(false);
const { dropdownOptions, addDropdownOptions } = useGroups();
const currentGroup = useMemo(() => {
return dropdownOptions?.find((g) => g.name === group?.name);
}, [group, dropdownOptions]);
const peerCount = useMemo(() => {
let peerCount = currentGroup?.peers_count ?? 0;
let countedPeers = currentGroup?.peers?.length ?? 0;
if (peerCount !== countedPeers) {
peerCount = countedPeers;
}
return peerCount;
}, [currentGroup]);
const updateGroupOptions = (g: Group) => {
addDropdownOptions([g]);
onAssignmentChange && onAssignmentChange(g);
};
return (
<>
{currentGroup && editGroupPeersModal && (
<AssignPeerToGroupModal
useSave={useSave}
group={currentGroup}
onUpdate={(g) => updateGroupOptions(g)}
open={editGroupPeersModal}
setOpen={setEditGroupPeersModal}
/>
)}
<Badge
variant={variant}
className={cn(className, "px-3 gap-2 whitespace-nowrap")}
onClick={(e) => {
if (!currentGroup) return;
e.stopPropagation();
setEditGroupPeersModal(true);
}}
useHover={!!currentGroup}
>
{!currentGroup && <MonitorSmartphoneIcon size={12} />}
{currentGroup ? <>{peerCount} Peer(s)</> : children}
{currentGroup && (
<>
{currentGroup.name == "All" ? (
<EyeIcon size={12} />
) : (
<SquarePen size={12} />
)}
</>
)}
</Badge>
</>
);
}

View File

@@ -10,6 +10,7 @@ import ResourceCountBadge from "@components/ui/ResourceCountBadge";
type Props = {
group?: Group;
disableRedirect?: boolean;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
@@ -17,6 +18,7 @@ export default function PeerCountBadge({
group,
variant = "gray",
className,
disableRedirect = false,
}: Props) {
const router = useRouter();
const { dropdownOptions, groups } = useGroups();
@@ -35,7 +37,8 @@ export default function PeerCountBadge({
return peerCount;
}, [currentGroup]);
const canRedirect = !!group?.id && group?.name !== "All";
const canRedirect =
!!group?.id && group?.name !== "All" && !disableRedirect;
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
@@ -46,7 +49,7 @@ export default function PeerCountBadge({
const showResources = resourcesCount > 0 && peerCount === 0;
return showResources ? (
<ResourceCountBadge group={group} />
<ResourceCountBadge group={group} disableRedirect={disableRedirect} />
) : (
<Badge
variant={variant}

View File

@@ -7,15 +7,20 @@ import { Group } from "@/interfaces/Group";
type Props = {
group?: Group;
disableRedirect?: boolean;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
export default function ResourceCountBadge({ group }: Props) {
export default function ResourceCountBadge({
group,
disableRedirect = false,
}: Props) {
const router = useRouter();
const hasId = !!group?.id;
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (disableRedirect) return;
if (hasId) router.push(`/group?id=${group?.id}&tab=resources`);
};

View File

@@ -10,6 +10,12 @@ const smallBadgeVariants = cva("", {
white: "bg-white/20 border border-white/10 text-white",
sky: "bg-sky-900 border border-sky-500/20 text-white",
netbird: "bg-netbird-900 border border-netbird-400 text-netbird-300",
yellow: "bg-yellow-900 border border-yellow-500/20 text-yellow-400",
},
size: {
default:
"text-[0.4rem] relative -top-[.25px] leading-[0] py-[0.39rem] px-1 rounded-[3px]",
md: "text-[0.55rem] relative -top-[.25px] leading-[0] py-[0.45rem] px-1 rounded-[3px]",
},
},
});
@@ -27,15 +33,10 @@ export const SmallBadge = ({
textClassName,
variant = "green",
children,
size = "default",
}: Props) => {
return (
<span
className={cn(
smallBadgeVariants({ variant }),
"text-[7px] relative -top-[.25px] leading-[0] py-[0.39rem] px-1 rounded-[3px]",
className,
)}
>
<span className={cn(smallBadgeVariants({ variant, size }), className)}>
{children}
<span className={cn("relative top-[0.4px]", textClassName)}>{text}</span>
</span>

View File

@@ -4,22 +4,38 @@ import React, { useMemo, useState } from "react";
type Props = {
text?: string;
children?: React.ReactNode;
tooltipContent?: React.ReactNode;
className?: string;
maxChars?: number;
maxWidth?: string; // Optional CSS width value
hideTooltip?: boolean;
align?: "start" | "center" | "end";
alignOffset?: number;
side?: "top" | "right" | "bottom" | "left";
sideOffset?: number;
};
export default function TruncatedText({
text,
children,
tooltipContent,
className,
maxChars = 40,
maxWidth,
hideTooltip = false,
align,
alignOffset = 20,
side,
sideOffset = 4,
}: Readonly<Props>) {
const [isOverflowing, setIsOverflowing] = useState(false);
const [open, setOpen] = useState(false);
const contentRef = React.useRef<HTMLDivElement>(null);
const measureRef = React.useRef<HTMLSpanElement>(null);
const hasCustomChildren = !!children;
const content = children ?? text;
const charCount = useMemo(() => {
if (!text) return 0;
@@ -27,12 +43,17 @@ export default function TruncatedText({
}, [text]);
// Check for overflow on mount and when text/maxWidth changes
// When custom children are provided, use a hidden measurement element
// to detect overflow independently of children's own truncation
React.useEffect(() => {
const element = contentRef.current;
if (element) {
setIsOverflowing(element.scrollWidth > element.clientWidth);
const container = contentRef.current;
const measure = measureRef.current;
if (hasCustomChildren && container && measure) {
setIsOverflowing(measure.scrollWidth > container.clientWidth);
} else if (container) {
setIsOverflowing(container.scrollWidth > container.clientWidth);
}
}, [text, maxWidth]);
}, [text, children, maxWidth, hasCustomChildren]);
// If maxWidth is provided, use overflow detection
// Otherwise, fall back to character count logic
@@ -44,11 +65,28 @@ export default function TruncatedText({
? { maxWidth }
: { maxWidth: `${maxChars - 2}ch` };
const measureElement = hasCustomChildren && text && (
<span
ref={measureRef}
className="absolute invisible whitespace-nowrap pointer-events-none h-0 overflow-hidden"
aria-hidden="true"
>
{text}
</span>
);
if (isDisabled) {
return (
<div className="w-full min-w-0 inline-block" style={containerStyle}>
<div
className={cn(
"w-full min-w-0 inline-block",
hasCustomChildren && "relative",
)}
style={containerStyle}
>
{measureElement}
<div ref={contentRef} className={cn(className, "truncate")}>
{text}
{content}
</div>
</div>
);
@@ -57,25 +95,36 @@ export default function TruncatedText({
return (
<Tooltip delayDuration={650} open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild={true}>
<div className="w-full min-w-0 inline-block" style={containerStyle}>
<div
className={cn(
"w-full min-w-0 inline-block",
hasCustomChildren && "relative",
)}
style={containerStyle}
>
{measureElement}
<div ref={contentRef} className={cn(className, "truncate")}>
{text}
{content}
</div>
</div>
</TooltipTrigger>
<TooltipContent
alignOffset={20}
sideOffset={4}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
onClick={(e) => {
e.stopPropagation();
}}
className={cn(className, "px-3 py-1.5")}
>
<div className="text-neutral-300 flex flex-col gap-1">
<div className="max-w-xs break-all whitespace-normal text-xs">
{text}
{tooltipContent ?? (
<div className="text-neutral-300 flex flex-col gap-1">
<div className="max-w-xs break-all whitespace-normal text-xs">
{text}
</div>
</div>
</div>
)}
</TooltipContent>
</Tooltip>
);

View File

@@ -56,7 +56,7 @@ export default function AnalyticsProvider({ children }: Readonly<Props>) {
});
}
if (hjid && window._DATADOG_SYNTHETICS_BROWSER === undefined) {
hotjar.initialize(hjid, 6);
hotjar.initialize({ id: hjid, sv: 6 });
}
setInitialized(true);
}, []);

View File

@@ -8,7 +8,8 @@ import React, {
useState,
} from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { isNetBirdHosted } from "@utils/netbird";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import announcementFile from "../../announcements.json";
const ANNOUNCEMENTS_URL =
"https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json";
@@ -64,7 +65,9 @@ const getAnnouncements = async (): Promise<AnnouncementInfo[]> => {
let raw: Announcement[];
if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
if (isLocalDev()) {
raw = announcementFile as Announcement[];
} else if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
raw = stored.announcements;
} else {
const response = await fetch(ANNOUNCEMENTS_URL);

View File

@@ -13,7 +13,11 @@ const CountryContext = React.createContext(
countries: Country[] | undefined;
isLoading: boolean;
getRegionByPeer: (peer: Peer) => string;
getRegionText: (country_code: string, city_name: string) => string;
getRegionText: (
country_code: string,
city_name: string,
subdivision_code?: string,
) => string;
},
);
@@ -21,7 +25,11 @@ export default function CountryProvider({ children }: Props) {
const { isRestricted } = usePermissions();
const getRegionByPeer = (peer: Peer) => "Unknown";
const getRegionText = (country_code: string, city_name: string) => "Unknown";
const getRegionText = (
country_code: string,
city_name: string,
subdivision_code?: string,
) => "Unknown";
return isRestricted ? (
<CountryContext.Provider
@@ -47,12 +55,14 @@ function CountryProviderContent({ children }: Props) {
);
const getRegionText = useCallback(
(country_code: string, city_name: string) => {
(country_code: string, city_name: string, subdivision_code?: string) => {
if (!countries) return "Unknown";
const country = countries.find((c) => c.country_code === country_code);
if (!country) return "Unknown";
if (!city_name) return country.country_name;
return `${country.country_name}, ${city_name}`;
const parts = [country.country_name];
if (subdivision_code) parts.push(subdivision_code);
if (city_name) parts.push(city_name);
return parts.join(", ");
},
[countries],
);

View File

@@ -27,6 +27,8 @@ type DialogOptions = {
type?: "default" | "warning" | "danger" | "center";
children?: React.ReactNode;
maxWidthClass?: string;
hideIcon?: boolean;
center?: boolean;
};
export default function DialogProvider({ children }: Props) {
@@ -34,7 +36,7 @@ export default function DialogProvider({ children }: Props) {
isOpen: false,
});
const [dialogOptions, setDialogOptions] = useState<DialogOptions>();
const fn = useRef<Function>();
const fn = useRef<Function>(undefined);
const confirm = useCallback((data: DialogOptions): Promise<boolean> => {
return new Promise((resolve) => {
@@ -70,14 +72,14 @@ export default function DialogProvider({ children }: Props) {
onPointerDownOutside={(e) => e.preventDefault()}
>
<ModalHeader
center={dialogOptions.type == "center"}
center={dialogOptions.center ?? dialogOptions.type == "center"}
title={dialogOptions.title || "Confirmation"}
margin={"mt-1"}
description={
dialogOptions.description ||
"Are you sure you want to continue? This action cannot be undone."
}
icon={dialogTypes[dialogOptions.type || "default"]}
icon={dialogOptions.hideIcon ? "" : dialogTypes[dialogOptions.type || "default"]}
color={
dialogOptions.type == "default"
? "blue"

View File

@@ -1,8 +1,6 @@
"use client";
import "react-loading-skeleton/dist/skeleton.css";
import { netbirdTheme } from "@utils/theme";
import { Flowbite } from "flowbite-react";
import dynamic from "next/dynamic";
import { type ThemeProviderProps } from "next-themes/dist/types";
import * as React from "react";
@@ -26,11 +24,9 @@ export function GlobalThemeProvider({
disableTransitionOnChange
{...props}
>
<Flowbite theme={{ theme: netbirdTheme }}>
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
{children}
</SkeletonTheme>
</Flowbite>
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
{children}
</SkeletonTheme>
</NextThemesProvider>
);
}

View File

@@ -1,7 +1,12 @@
import { Modal } from "@components/modal/Modal";
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import { cloneDeep } from "@utils/helpers";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import { useGroups } from "@/contexts/GroupsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy } from "@/interfaces/Policy";
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
@@ -18,18 +23,115 @@ const PoliciesContext = React.createContext(
message?: string,
) => void;
createPolicy: (policy: Policy) => Promise<Policy>;
createPoliciesForResource: (
policies: Policy[],
resource: NetworkResource,
knownGroups?: Group[],
) => Promise<void>;
openEditPolicyModal: (policy: Policy, tab?: string) => void;
deletePolicy: (policy: Policy, onSuccess?: () => void) => Promise<void>;
serializeRules: (
rules: Policy["rules"],
enabled?: boolean,
) => Policy["rules"];
},
);
export default function PoliciesProvider({ children }: Props) {
const { mutate } = useSWRConfig();
const request = useApiCall<Policy>("/policies");
const { createOrUpdate: createOrUpdateGroup, groups } = useGroups();
const [policyModal, setPolicyModal] = useState(false);
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
const [initialPolicyTab, setInitialPolicyTab] = useState("");
const createPolicy = async (policy: Policy) => request.post(policy);
const createPolicyForResource = async (
policy: Policy,
resource: NetworkResource,
knownGroups?: Group[],
) => {
const rule = policy.rules[0];
const allGroups = [...(knownGroups || []), ...(groups || [])];
const resolveGroup = async (g: Group | string): Promise<string> => {
if (typeof g === "string") return g;
if (g.id) return g.id;
const existing = allGroups.find((eg) => eg.name === g.name);
if (existing?.id) return existing.id;
const created = await createOrUpdateGroup(g);
return created.id!;
};
const sources = await Promise.all(
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const destinations = rule.destinationResource
? undefined
: await Promise.all((rule.destinations ?? []).map(resolveGroup)).then(
(ids) => ids.filter(Boolean) as string[],
);
const destinationResource = rule.destinationResource
? { id: resource.id, type: resource.type }
: undefined;
return createPolicy({
...policy,
source_posture_checks: (policy.source_posture_checks ?? []).map((c) =>
typeof c === "string" ? c : c.id,
),
rules: [
{
...rule,
sources,
destinations,
destinationResource,
},
],
} as Policy);
};
const createPoliciesForResource = async (
newPolicies: Policy[],
resource: NetworkResource,
knownGroups?: Group[],
) => {
const policiesToCreate = newPolicies.filter((p) => !p.id);
if (policiesToCreate.length === 0) return;
await Promise.all(
policiesToCreate.map((p) =>
createPolicyForResource(p, resource, knownGroups),
),
);
await mutate("/policies");
};
const serializeRules = (rules: Policy["rules"], enabled?: boolean) => {
rules = cloneDeep(rules);
rules.forEach((rule) => {
if (enabled !== undefined) rule.enabled = enabled;
rule.sources = rule.sources
? (rule.sources.map((s) => {
const group = s as Group;
return group.id ?? s;
}) as string[])
: [];
rule.destinations = rule.destinations
? (rule.destinations.map((d) => {
const group = d as Group;
return group.id ?? d;
}) as string[])
: [];
if (rule.destinationResource) rule.destinations = null;
if (rule.sourceResource) rule.sources = null;
});
return rules;
};
const updatePolicy = async (
policy: Policy,
toUpdate: Partial<Policy>,
@@ -62,6 +164,20 @@ export default function PoliciesProvider({ children }: Props) {
});
};
const deletePolicy = async (policy: Policy, onSuccess?: () => void) => {
const promise = request.del("", `/${policy.id}`).then(() => {
mutate("/policies");
onSuccess?.();
});
notify({
title: "Access Control Policy " + policy.name,
description: "The policy was successfully deleted.",
promise,
loadingMessage: "Deleting policy...",
});
return promise;
};
const openEditPolicyModal = (policy: Policy, tab?: string) => {
setCurrentPolicy(policy);
tab && setInitialPolicyTab(tab);
@@ -70,7 +186,14 @@ export default function PoliciesProvider({ children }: Props) {
return (
<PoliciesContext.Provider
value={{ updatePolicy, createPolicy, openEditPolicyModal }}
value={{
updatePolicy,
createPolicy,
createPoliciesForResource,
openEditPolicyModal,
deletePolicy,
serializeRules,
}}
>
{children}
<Modal

View File

@@ -0,0 +1,638 @@
"use client";
import { notify } from "@components/Notification";
import useFetchApi, { useApiCall } from "@utils/api";
import React, {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import {
ReverseProxy,
ReverseProxyDomain,
ReverseProxyFlatTarget,
ReverseProxyTarget,
ReverseProxyTargetProtocol,
ReverseProxyTargetType,
} from "@/interfaces/ReverseProxy";
import ReverseProxyModal from "@/modules/reverse-proxy/ReverseProxyModal";
import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal";
type ReverseProxiesContextValue = {
reverseProxies: ReverseProxy[] | undefined;
resources: NetworkResource[] | undefined;
peers: Peer[] | undefined;
isLoading: boolean;
openModal: (options?: OpenModalOptions) => void;
openTargetModal: (options: OpenTargetModalOptions) => void;
handleCreateOrUpdateProxy: (options: HandleCreateOrUpdateOptions) => void;
resolveDestination: (target: ReverseProxyTarget) => string;
handleToggle: (proxy: ReverseProxy) => Promise<void>;
handleDelete: (proxy: ReverseProxy) => Promise<void>;
handleDeleteTarget: (
proxy: ReverseProxy,
target: ReverseProxyTarget,
) => Promise<void>;
handleToggleTarget: (
proxy: ReverseProxy,
target: ReverseProxyTarget,
) => Promise<void>;
domains: ReverseProxyDomain[] | undefined;
isLoadingDomains: boolean;
validateDomain: (domainId: string) => Promise<void>;
deleteDomain: (domain: ReverseProxyDomain) => Promise<void>;
createDomain: (
domain: string,
targetCluster: string,
) => Promise<ReverseProxyDomain>;
};
type OpenModalOptions = {
proxy?: ReverseProxy;
initialTab?: string;
initialPeer?: Peer;
initialNetwork?: Network;
initialResource?: NetworkResource;
onSuccess?: () => void;
};
type OpenTargetModalOptions = {
proxy: ReverseProxy;
target?: ReverseProxyTarget;
};
type HandleCreateOrUpdateOptions = {
data: Partial<ReverseProxy>;
proxyId?: string;
onSuccess?: () => void;
};
type Props = {
children: React.ReactNode;
initialPeer?: Peer;
initialNetwork?: Network;
};
const ReverseProxiesContext = createContext<ReverseProxiesContextValue | null>(
null,
);
export default function ReverseProxiesProvider({
children,
initialPeer,
initialNetwork,
}: Readonly<Props>) {
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
// Reverse Proxies
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
"/reverse-proxies/services",
);
const request = useApiCall<ReverseProxy>("/reverse-proxies/services", true);
// Peers & Resources for resolving target destinations
const { data: peers } = useFetchApi<Peer[]>("/peers");
const { data: resources } = useFetchApi<NetworkResource[]>(
"/networks/resources",
);
const resolveDestination = useCallback(
(target: ReverseProxyTarget) => {
if (target.host === "unknown") return target.host;
const host = resolveTargetHost(target, peers, resources);
return formatTargetDestination(target, host);
},
[peers, resources],
);
const reverseProxies = useMemo(() => {
return rawReverseProxies?.map((proxy) => ({
...proxy,
targets: proxy.targets.map((target) => ({
...target,
destination: resolveDestination(target),
})),
}));
}, [rawReverseProxies, resolveDestination]);
// Domains
const { data: domains, isLoading: isLoadingDomains } = useFetchApi<
ReverseProxyDomain[]
>("/reverse-proxies/domains");
const domainRequest = useApiCall<ReverseProxyDomain>(
"/reverse-proxies/domains",
true,
);
const [modalOpen, setModalOpen] = useState(false);
const [currentProxy, setCurrentProxy] = useState<ReverseProxy | undefined>();
const [initialTab, setInitialTab] = useState<string | undefined>();
const [modalInitialPeer, setModalInitialPeer] = useState<Peer | undefined>();
const [modalInitialNetwork, setModalInitialNetwork] = useState<
Network | undefined
>();
const [targetModalOpen, setTargetModalOpen] = useState(false);
const [targetModalProxy, setTargetModalProxy] = useState<
ReverseProxy | undefined
>();
const [editingTarget, setEditingTarget] = useState<
ReverseProxyTarget | undefined
>();
const [modalInitialResource, setModalInitialResource] = useState<
NetworkResource | undefined
>();
const onSuccessRef = React.useRef<(() => void) | undefined>(undefined);
const openModal = useCallback(
(options?: OpenModalOptions) => {
setCurrentProxy(options?.proxy);
setInitialTab(options?.initialTab);
setModalInitialPeer(options?.initialPeer ?? initialPeer);
setModalInitialNetwork(options?.initialNetwork ?? initialNetwork);
setModalInitialResource(options?.initialResource);
onSuccessRef.current = options?.onSuccess;
setModalOpen(true);
},
[initialPeer, initialNetwork],
);
const closeModal = useCallback(() => {
setModalOpen(false);
setCurrentProxy(undefined);
setInitialTab(undefined);
setModalInitialPeer(undefined);
setModalInitialNetwork(undefined);
setModalInitialResource(undefined);
onSuccessRef.current = undefined;
}, []);
const openTargetModal = useCallback((options: OpenTargetModalOptions) => {
setTargetModalProxy(options.proxy);
setEditingTarget(options.target);
setTargetModalOpen(true);
}, []);
const closeTargetModal = useCallback(() => {
setTargetModalOpen(false);
setTargetModalProxy(undefined);
setEditingTarget(undefined);
}, []);
const handleSaveTarget = useCallback(
async (target: ReverseProxyTarget) => {
if (!targetModalProxy) return;
let updatedTargets: ReverseProxyTarget[];
const isEditing = !!editingTarget;
const proxyId = targetModalProxy.id;
if (isEditing) {
// Update existing target - match by index against the original target
const targetIndex = targetModalProxy.targets.indexOf(editingTarget);
updatedTargets = targetModalProxy.targets.map((t, i) => {
return i === targetIndex ? target : t;
});
} else {
// Add new target
updatedTargets = [...(targetModalProxy.targets || []), target];
}
notify({
title: targetModalProxy.domain,
description: isEditing
? "Target updated successfully"
: "Target added successfully",
promise: request
.put(
{ ...targetModalProxy, targets: sanitizeTargets(updatedTargets) },
`/${targetModalProxy.id}`,
)
.then(() => {
mutate("/reverse-proxies/services");
// After adding a new target, scroll to the row and open the accordion
if (!isEditing) {
setTimeout(() => {
const row = document.querySelector<HTMLElement>(
`[data-row-id="${proxyId}"]`,
);
if (row?.getAttribute("data-accordion") === "closed") {
row?.click();
}
row?.scrollIntoView({ behavior: "smooth" });
}, 200);
}
}),
loadingMessage: isEditing ? "Updating target..." : "Adding target...",
});
closeTargetModal();
},
[targetModalProxy, editingTarget, request, mutate, closeTargetModal],
);
const handleCreateOrUpdateProxy = useCallback(
({ data, proxyId, onSuccess }: HandleCreateOrUpdateOptions) => {
const sanitizedData = {
...data,
targets: data.targets ? sanitizeTargets(data.targets) : undefined,
};
const isCreating = !proxyId;
const promise = isCreating
? request.post(sanitizedData)
: request.put(sanitizedData, `/${proxyId}`);
notify({
title: data.domain || "",
description: isCreating
? "Service was successfully created"
: "Service was successfully updated",
promise: promise.then((result) => {
mutate("/reverse-proxies/services");
onSuccess?.();
if (isCreating && result?.id) {
setTimeout(() => {
const row = document.querySelector<HTMLElement>(
`[data-row-id="${result.id}"]`,
);
if (row?.getAttribute("data-accordion") === "closed") {
row?.click();
}
row?.scrollIntoView({ behavior: "smooth" });
}, 200);
}
}),
loadingMessage: isCreating
? "Creating service..."
: "Updating service...",
});
},
[request, mutate],
);
const handleToggle = useCallback(
async (proxy: ReverseProxy) => {
const newEnabled = !proxy.enabled;
notify({
title: proxy.domain,
description: `Reverse proxy ${newEnabled ? "enabled" : "disabled"}`,
promise: request
.put(
{
...proxy,
enabled: newEnabled,
targets: sanitizeTargets(proxy.targets),
},
`/${proxy.id}`,
)
.then(() => {
mutate("/reverse-proxies/services");
}),
loadingMessage: `${
newEnabled ? "Enabling" : "Disabling"
} reverse proxy...`,
});
},
[mutate, request],
);
const handleToggleTarget = useCallback(
async (proxy: ReverseProxy, target: ReverseProxyTarget) => {
const newEnabled = !target.enabled;
const targetIndex = proxy.targets.indexOf(target);
const updatedTargets = proxy.targets.map((t, i) => {
return i === targetIndex ? { ...t, enabled: newEnabled } : t;
});
notify({
title: proxy.domain,
description: `Target ${newEnabled ? "enabled" : "disabled"}`,
promise: request
.put(
{ ...proxy, targets: sanitizeTargets(updatedTargets) },
`/${proxy.id}`,
)
.then(() => {
mutate("/reverse-proxies/services");
}),
loadingMessage: `${newEnabled ? "Enabling" : "Disabling"} target...`,
});
},
[mutate, request],
);
const handleDelete = useCallback(
async (proxy: ReverseProxy) => {
const choice = await confirm({
title: `Delete '${proxy.domain}'?`,
description:
"Are you sure you want to delete this reverse proxy? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: proxy.domain,
description: "Reverse proxy was successfully deleted",
promise: request.del({}, `/${proxy.id}`).then(() => {
mutate("/reverse-proxies/services");
}),
loadingMessage: "Deleting reverse proxy...",
});
},
[confirm, request, mutate],
);
const handleDeleteTarget = useCallback(
async (proxy: ReverseProxy, target: ReverseProxyTarget) => {
const isOnlyTarget = proxy.targets.length <= 1;
const choice = await confirm({
title: isOnlyTarget ? `Delete '${proxy.domain}'?` : `Delete target?`,
description: isOnlyTarget
? "This is the only target for this service. Deleting it will remove the entire service. This action cannot be undone."
: "Are you sure you want to delete this target? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
if (isOnlyTarget) {
notify({
title: proxy.domain,
description: "Service was successfully deleted",
promise: request.del({}, `/${proxy.id}`).then(() => {
mutate("/reverse-proxies/services");
}),
loadingMessage: "Deleting service...",
});
} else {
const targetIndex = proxy.targets.indexOf(target);
const updatedTargets = proxy.targets.filter(
(_, i) => i !== targetIndex,
);
notify({
title: proxy.domain,
description: "Target was successfully deleted",
promise: request
.put(
{ ...proxy, targets: sanitizeTargets(updatedTargets) },
`/${proxy.id}`,
)
.then(() => {
mutate("/reverse-proxies/services");
}),
loadingMessage: "Deleting target...",
});
}
},
[confirm, request, mutate],
);
const createDomain = useCallback(
async (
domain: string,
targetCluster: string,
): Promise<ReverseProxyDomain> => {
const promise = domainRequest
.post({
domain,
target_cluster: targetCluster,
})
.then((d) => {
mutate("/reverse-proxies/domains");
return d;
});
notify({
title: "Add Custom Domain",
description: "Domain successfully added",
promise,
loadingMessage: "Adding domain...",
});
return promise;
},
[domainRequest, mutate],
);
const validateDomain = useCallback(
async (domainId: string) => {
// Delay refetch to allow the server to propagate the validation result
const DOMAIN_VALIDATION_REFETCH_DELAY_MS = 2000;
notify({
title: "Domain Validation",
description: "Domain validation started",
promise: domainRequest.get(`/${domainId}/validate`).then(() => {
setTimeout(() => {
mutate("/reverse-proxies/domains");
}, DOMAIN_VALIDATION_REFETCH_DELAY_MS);
}),
loadingMessage: "Validating domain...",
});
},
[domainRequest, mutate],
);
const deleteDomain = useCallback(
async (domain: ReverseProxyDomain) => {
const choice = await confirm({
title: `Delete '${domain.domain}'?`,
description:
"Are you sure you want to delete this domain? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: domain.domain,
description: "Domain was successfully deleted",
promise: domainRequest.del({}, `/${domain.id}`).then(() => {
mutate("/reverse-proxies/domains");
}),
loadingMessage: "Deleting domain...",
});
},
[confirm, domainRequest, mutate],
);
return (
<ReverseProxiesContext.Provider
value={{
reverseProxies,
resources,
peers,
isLoading,
openModal,
openTargetModal,
handleCreateOrUpdateProxy,
handleToggle,
handleToggleTarget,
handleDelete,
handleDeleteTarget,
resolveDestination,
domains,
isLoadingDomains,
createDomain,
validateDomain,
deleteDomain,
}}
>
{children}
{modalOpen && (
<ReverseProxyModal
open={modalOpen}
onOpenChange={(open) => {
if (!open) closeModal();
}}
reverseProxy={currentProxy}
domains={domains}
initialTab={initialTab}
initialPeer={modalInitialPeer}
initialNetwork={modalInitialNetwork}
initialResource={modalInitialResource}
initialSubdomain={modalInitialResource?.name}
onSuccess={onSuccessRef.current}
/>
)}
{targetModalOpen && targetModalProxy && (
<ReverseProxyTargetModal
key={targetModalOpen ? 1 : 0}
open={targetModalOpen}
onOpenChange={(open) => {
if (!open) closeTargetModal();
}}
onSave={handleSaveTarget}
currentTarget={editingTarget}
reverseProxy={targetModalProxy}
initialPeer={initialPeer}
initialNetwork={initialNetwork}
/>
)}
</ReverseProxiesContext.Provider>
);
}
export const useReverseProxies = () => {
const context = useContext(ReverseProxiesContext);
if (!context) {
throw new Error(
"useReverseProxies must be used within a ReverseProxiesProvider",
);
}
return context;
};
type FlattenReverseProxiesParams = {
reverseProxies: ReverseProxy[] | undefined;
peer?: Peer;
network?: Network;
};
export function flattenReverseProxies({
reverseProxies,
peer,
network,
}: FlattenReverseProxiesParams): ReverseProxyFlatTarget[] {
if (!reverseProxies) return [];
const flattened: ReverseProxyFlatTarget[] = [];
reverseProxies.forEach((proxy) => {
proxy.targets.forEach((target) => {
// Filter by peer if provided
if (peer) {
if (
target.target_type !== ReverseProxyTargetType.PEER ||
target.target_id !== peer.id
) {
return;
}
}
// Filter by network if provided (check if target resource belongs to network)
if (network && !peer) {
if (isResourceTargetType(target.target_type)) {
const isResourceInNetwork = network.resources?.includes(
target.target_id || "",
);
if (!isResourceInNetwork) return;
} else {
// For peer targets in network context, skip them
return;
}
}
flattened.push({
...target,
proxy,
});
});
});
return flattened;
}
export function sanitizeTargets(
targets: ReverseProxyTarget[],
): ReverseProxyTarget[] {
return targets.map((t) => {
const { destination: _, ...target } = t;
if (t.target_type === ReverseProxyTargetType.SUBNET)
return target as ReverseProxyTarget;
const { host: __, ...rest } = target;
return rest as ReverseProxyTarget;
});
}
export function isResourceTargetType(type: ReverseProxyTargetType): boolean {
return (
type === ReverseProxyTargetType.HOST ||
type === ReverseProxyTargetType.DOMAIN ||
type === ReverseProxyTargetType.SUBNET
);
}
function formatTargetDestination(
target: ReverseProxyTarget,
resolvedHost?: string,
): string {
const host = target.host || resolvedHost || "localhost";
const isDefault =
(target.protocol === "http" && target.port === 80) ||
(target.protocol === "https" && target.port === 443) ||
target.port === 0;
return isDefault
? `${target.protocol}://${host}`
: `${target.protocol}://${host}:${target.port}`;
}
export function defaultPortForProtocol(
protocol: ReverseProxyTargetProtocol,
): number {
return protocol === ReverseProxyTargetProtocol.HTTPS ? 443 : 80;
}
function resolveTargetHost(
target: ReverseProxyTarget,
peers?: Peer[],
resources?: NetworkResource[],
): string {
if (target.host) return "";
if (target.target_type === ReverseProxyTargetType.PEER) {
return peers?.find((p) => p.id === target.target_id)?.ip ?? "";
}
if (isResourceTargetType(target.target_type)) {
const address = resources?.find((r) => r.id === target.target_id)?.address;
if (!address) return "";
return address.includes("/") ? address.split("/")[0] : address;
}
return "";
}

View File

@@ -0,0 +1,239 @@
"use client";
import useFetchApi from "@utils/api";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useSWRConfig } from "swr";
import { Pagination } from "@/interfaces/Pagination";
type ServerPaginationContextValue<T = unknown> = {
data?: T;
isLoading: boolean;
isFetching: boolean;
mutate: () => Promise<unknown>;
pagination: { pageIndex: number; pageSize: number };
pageCount: number;
totalRecords?: number;
onPaginationChange: (pagination: {
pageIndex: number;
pageSize: number;
}) => void;
globalFilter: string;
onGlobalFilterChange: (value: string) => void;
setFilter: (key: string, value: string | undefined) => void;
getFilter: (key: string) => string | undefined;
setSort: (name: string, direction: "asc" | "desc") => void;
hasActiveFilters: boolean;
resetFilters: () => void;
onFilterReset: () => void;
hasServerSideFilters: boolean;
serverSidePagination: true;
manualPagination: true;
manualFiltering: true;
keepStateInLocalStorage: false;
};
const ServerPaginationContext =
createContext<ServerPaginationContextValue | null>(null);
type ProviderProps = {
url: string;
defaultPageSize?: number;
defaultFilters?: Record<string, string>;
children: React.ReactNode;
};
export default function ServerPaginationProvider({
url,
defaultPageSize = 50,
defaultFilters,
children,
}: Readonly<ProviderProps>) {
const { mutate: swrMutate } = useSWRConfig();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const [search, setSearch] = useState("");
const [filters, setFilters] = useState<Record<string, string | undefined>>(
defaultFilters ?? {},
);
const buildUrl = useCallback(
(
p: number,
ps: number,
s: string,
f: Record<string, string | undefined>,
) => {
const params = new URLSearchParams();
params.set("page", String(p));
params.set("page_size", String(ps));
if (s) params.set("search", s.toLowerCase());
Object.entries(f).forEach(([key, value]) => {
if (value !== undefined) params.set(key, value);
});
return `${url}?${params.toString()}`;
},
[url],
);
const apiUrl = buildUrl(page, pageSize, search, filters);
const {
data: response,
isLoading,
isValidating,
} = useFetchApi<Pagination<unknown>>(apiUrl);
const hasLoadedOnce = useRef(false);
const previousResponse = useRef<Pagination<unknown> | undefined>(undefined);
if (response?.data) {
hasLoadedOnce.current = true;
previousResponse.current = response;
}
const activeResponse = response ?? previousResponse.current;
const totalPages = activeResponse?.total_pages ?? 0;
const hasNextPage = page < totalPages;
// Prefetch next page
useFetchApi<Pagination<unknown>>(
hasNextPage ? buildUrl(page + 1, pageSize, search, filters) : apiUrl,
false,
false,
hasNextPage,
);
const onPaginationChange = useCallback(
(pagination: { pageIndex: number; pageSize: number }) => {
if (pagination.pageSize !== pageSize) {
setPage(1);
setPageSize(pagination.pageSize);
} else {
setPage(pagination.pageIndex + 1);
}
},
[pageSize],
);
const onGlobalFilterChange = useCallback((value: string) => {
setSearch(value);
setPage(1);
}, []);
const filterTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const pendingFilters = useRef<Record<string, string | undefined>>({});
useEffect(() => () => clearTimeout(filterTimeout.current), []);
const setFilter = useCallback((key: string, value: string | undefined) => {
pendingFilters.current[key] = value;
clearTimeout(filterTimeout.current);
filterTimeout.current = setTimeout(() => {
const pending = pendingFilters.current;
pendingFilters.current = {};
setFilters((prev) => ({ ...prev, ...pending }));
setPage(1);
}, 100);
}, []);
const getFilter = useCallback((key: string) => filters[key], [filters]);
const setSort = useCallback((name: string, direction: "asc" | "desc") => {
setFilters((prev) => ({
...prev,
sort_by: name,
sort_order: direction,
}));
setPage(1);
}, []);
const hasActiveFilters =
search !== "" ||
Object.entries(filters).some(
([key, value]) => value !== (defaultFilters ?? {})[key],
);
const resetFilters = useCallback(() => {
setSearch("");
setFilters(defaultFilters ?? {});
setPage(1);
}, [defaultFilters]);
const mutate = useCallback(() => {
return swrMutate(apiUrl);
}, [swrMutate, apiUrl]);
const value = useMemo<ServerPaginationContextValue>(
() => ({
data: activeResponse?.data,
isLoading: isLoading && !hasLoadedOnce.current,
isFetching: isValidating,
mutate,
setFilter,
getFilter,
setSort,
hasActiveFilters,
resetFilters,
pagination: { pageIndex: page - 1, pageSize },
pageCount: totalPages,
totalRecords: activeResponse?.total_records,
onPaginationChange,
manualPagination: true,
serverSidePagination: true,
manualFiltering: true,
keepStateInLocalStorage: false,
globalFilter: search,
onGlobalFilterChange,
onFilterReset: resetFilters,
hasServerSideFilters: hasActiveFilters,
}),
[
activeResponse?.data,
activeResponse?.total_records,
isLoading,
isValidating,
mutate,
setFilter,
getFilter,
setSort,
hasActiveFilters,
resetFilters,
page,
pageSize,
totalPages,
onPaginationChange,
search,
onGlobalFilterChange,
],
);
return (
<ServerPaginationContext.Provider value={value}>
{children}
</ServerPaginationContext.Provider>
);
}
export function useServerPagination<T>() {
const context = useContext(ServerPaginationContext);
if (!context) {
throw new Error(
"useServerPagination must be used within a ServerPaginationProvider",
);
}
return context as ServerPaginationContextValue<T>;
}
export function useOptionalServerPagination<T>() {
const context = useContext(ServerPaginationContext);
return context as ServerPaginationContextValue<T> | null;
}

View File

@@ -28,14 +28,30 @@ const UserProfileContext = React.createContext(
);
export default function UsersProvider({ children }: Readonly<Props>) {
const { data: users, mutate, isLoading } = useFetchApi<User[]>("/users");
const { data: users, mutate, isLoading } = useFetchApi<User[]>(
"/users?service_user=false",
);
const { data: serviceUsers, mutate: mutateServiceUsers, isLoading: isLoadingServiceUsers } = useFetchApi<
User[]
>("/users?service_user=true");
const refresh = () => {
mutate().then();
mutateServiceUsers().then();
};
const allUsers = useMemo(() => {
return [...(users ?? []), ...(serviceUsers ?? [])];
}, [users, serviceUsers]);
return (
<UsersContext.Provider value={{ users, refresh, isLoading }}>
<UsersContext.Provider
value={{
users: allUsers,
refresh,
isLoading: isLoading || isLoadingServiceUsers,
}}
>
<UserProfileProvider>{children}</UserProfileProvider>
</UsersContext.Provider>
);

View File

@@ -0,0 +1,17 @@
import { useAccount } from "@/modules/account/useAccount";
import { SSOIdentityProvider } from "@/interfaces/IdentityProvider";
import useFetchApi from "@utils/api";
export function useEmbeddedIdentityProviders() {
const account = useAccount();
const isEmbeddedIdPEnabled = !!account?.settings?.embedded_idp_enabled;
const { data: providers } = useFetchApi<SSOIdentityProvider[]>(
"/identity-providers",
true,
true,
isEmbeddedIdPEnabled,
);
return { providers, isEmbeddedIdPEnabled };
}

View File

@@ -1,6 +1,6 @@
import { RefObject, useEffect, useRef, useState } from "react";
export default function useIsVisible(ref: RefObject<HTMLElement>) {
export default function useIsVisible(ref: RefObject<HTMLElement | null>) {
const observerRef = useRef<IntersectionObserver | null>(null);
const [isOnScreen, setIsOnScreen] = useState(false);

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T>();
const ref = useRef<T>(undefined);
useEffect(() => {
ref.current = value;

34
src/hooks/useUrlTab.ts Normal file
View File

@@ -0,0 +1,34 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
export default function useUrlTab(
validTabs: string[],
defaultTab: string,
paramName: string = "tab",
): [string, (value: string) => void] {
const searchParams = useSearchParams();
const router = useRouter();
const getTab = useCallback(
(params: URLSearchParams) => {
const tabParam = params.get(paramName);
if (tabParam && validTabs.includes(tabParam)) return tabParam;
return defaultTab;
},
[validTabs, defaultTab, paramName],
);
const tab = useMemo(() => getTab(searchParams), [searchParams, getTab]);
const setTab = useCallback(
(value: string) => {
const nextTab = validTabs.includes(value) ? value : defaultTab;
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, nextTab);
router.replace(`?${params.toString()}`, { scroll: false });
},
[searchParams, router, validTabs, defaultTab, paramName],
);
return [tab, setTab];
}

View File

@@ -10,6 +10,8 @@ export interface Account {
user_approval_required: boolean;
};
peer_login_expiration_enabled: boolean;
peer_expose_enabled?: boolean;
peer_expose_groups?: string[];
peer_login_expiration: number;
peer_inactivity_expiration_enabled: boolean;
peer_inactivity_expiration: number;
@@ -24,6 +26,7 @@ export interface Account {
lazy_connection_enabled: boolean;
embedded_idp_enabled?: boolean;
auto_update_version: string;
auto_update_always: boolean;
local_auth_disabled?: boolean;
};
onboarding?: AccountOnboarding;

View File

@@ -1,20 +1,22 @@
export interface GoogleWorkspaceIntegration {
id: string;
customerId: string;
syncInterval: number;
customer_id: string;
sync_interval: number;
enabled: boolean;
group_prefixes: string[];
user_group_prefixes: string[];
connector_id?: string;
}
export interface AzureADIntegration {
id: string;
clientId: string;
tenantId: string;
syncInterval: number;
client_id: string;
tenant_id: string;
sync_interval: number;
enabled: boolean;
group_prefixes: string[];
user_group_prefixes: string[];
connector_id?: string;
}
export interface OktaIntegration {
@@ -23,6 +25,8 @@ export interface OktaIntegration {
group_prefixes: string[];
user_group_prefixes: string[];
auth_token: string;
connection_name?: string;
connector_id?: string;
}
export interface IdentityProviderLog {

View File

@@ -17,6 +17,7 @@ export interface NetworkRouter {
metric: number;
masquerade: boolean;
enabled: boolean;
search?: string;
}
export interface NetworkResource {

View File

@@ -33,6 +33,8 @@ export interface Permissions {
proxy: Permission;
proxy_configuration: Permission;
services: Permission;
};
}

View File

@@ -0,0 +1,216 @@
export enum ServiceMode {
HTTP = "http",
TCP = "tcp",
UDP = "udp",
TLS = "tls",
}
export interface ReverseProxy {
id?: string;
name: string;
domain: string;
mode?: ServiceMode;
listen_port?: number;
port_auto_assigned?: boolean;
proxy_cluster?: string;
targets: ReverseProxyTarget[];
enabled: boolean;
pass_host_header?: boolean;
rewrite_redirects?: boolean;
auth?: ReverseProxyAuth;
access_restrictions?: AccessRestrictions;
meta?: ReverseProxyMeta;
}
export const CrowdSecMode = {
OFF: "off",
ENFORCE: "enforce",
OBSERVE: "observe",
} as const;
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
export interface AccessRestrictions {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
crowdsec_mode?: CrowdSecMode;
}
export interface ReverseProxyMeta {
created_at: string;
status: ReverseProxyStatus;
certificate_issued_at?: string;
}
export enum ReverseProxyStatus {
PENDING = "pending",
ACTIVE = "active",
TUNNEL_NOT_CREATED = "tunnel_not_created",
CERTIFICATE_PENDING = "certificate_pending",
CERTIFICATE_FAILED = "certificate_failed",
ERROR = "error",
}
export type ServiceTargetOptionsPathRewrite = "preserve";
export interface ServiceTargetOptions {
skip_tls_verify?: boolean;
request_timeout?: string;
session_idle_timeout?: string;
path_rewrite?: ServiceTargetOptionsPathRewrite;
custom_headers?: Record<string, string>;
proxy_protocol?: boolean;
}
export interface ReverseProxyTarget {
target_id?: string;
target_type: ReverseProxyTargetType;
path?: string;
protocol: ReverseProxyTargetProtocol;
host?: string;
port: number;
enabled: boolean;
access_local?: boolean;
options?: ServiceTargetOptions;
// Frontend
destination?: string;
}
export interface ReverseProxyAuth {
password_auth?: {
enabled: boolean;
password: string;
};
pin_auth?: {
enabled: boolean;
pin: string;
};
bearer_auth?: {
enabled: boolean;
distribution_groups: string[];
};
link_auth?: {
enabled: boolean;
};
header_auths?: HeaderAuthConfig[];
}
export interface HeaderAuthConfig {
enabled: boolean;
header: string;
value: string;
}
export interface ReverseProxyDomain {
id: string;
domain: string;
validated: boolean;
type: ReverseProxyDomainType;
target_cluster?: string;
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
}
export enum ReverseProxyDomainType {
FREE = "free",
CUSTOM = "custom",
}
export enum ReverseProxyTargetType {
PEER = "peer",
HOST = "host",
DOMAIN = "domain",
SUBNET = "subnet",
}
export enum ReverseProxyTargetProtocol {
HTTP = "http",
HTTPS = "https",
TCP = "tcp",
UDP = "udp",
}
export enum EventProtocol {
HTTP = "http",
TCP = "tcp",
UDP = "udp",
TLS = "tls",
}
export interface ReverseProxyEvent {
id: string;
service_id: string;
timestamp: string;
method: string;
host: string;
path: string;
duration_ms: number;
status_code: number;
source_ip: string;
reason?: string;
user_id?: string;
auth_method_used?: string;
country_code?: string;
city_name?: string;
subdivision_code?: string;
bytes_upload: number;
bytes_download: number;
protocol?: EventProtocol;
metadata?: Record<string, string>;
}
export function isL4Event(event: ReverseProxyEvent): boolean {
return (
event.protocol === EventProtocol.TCP ||
event.protocol === EventProtocol.UDP ||
event.protocol === EventProtocol.TLS
);
}
export interface ReverseProxyFlatTarget extends ReverseProxyTarget {
proxy: ReverseProxy;
}
export function isL4Mode(mode?: ServiceMode): boolean {
return (
mode === ServiceMode.TCP ||
mode === ServiceMode.UDP ||
mode === ServiceMode.TLS
);
}
export const REVERSE_PROXY_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy";
export const REVERSE_PROXY_SERVICES_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#services";
export const REVERSE_PROXY_TARGETS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#targets";
export const REVERSE_PROXY_AUTHENTICATION_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/authentication";
export const REVERSE_PROXY_SETTINGS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#step-4-configure-advanced-settings";
export const REVERSE_PROXY_CLUSTERS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#self-hosted-proxy-setup";
export const REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/custom-domains";
export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK =
"https://docs.netbird.io/manage/reverse-proxy/custom-domains#validating-a-custom-domain";
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
export const REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy";
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";

View File

@@ -9,7 +9,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
import { Viewport } from "next";
import localFont from "next/font/local";
import React, { Suspense } from "react";
import { Toaster } from "react-hot-toast";
import { Toaster } from "sonner";
import OIDCProvider from "@/auth/OIDCProvider";
import FullScreenLoading from "@/components/ui/FullScreenLoading";
import AnalyticsProvider, {
@@ -42,7 +42,7 @@ export default function AppLayout({
<head>
<GoogleTagManagerHeadScript />
</head>
<body className={cn(inter.className, "dark:bg-nb-gray bg-gray-50")}>
<body className={cn(inter.className)}>
<Suspense fallback={<FullScreenLoading />}>
<AnalyticsProvider>
<DialogProvider>
@@ -59,10 +59,13 @@ export default function AppLayout({
</GlobalThemeProvider>
</DialogProvider>
<Toaster
position={"top-center"}
toastOptions={{
duration: 3000,
}}
position="top-center"
duration={3000}
toastOptions={{ unstyled: true }}
style={{ "--width": "28rem" } as React.CSSProperties}
gap={0}
visibleToasts={5}
offset="12px"
/>
<NavigationEvents />
<DisableDarkReader />

View File

@@ -1,11 +1,8 @@
"use client";
import { ScrollArea } from "@components/ScrollArea";
import { SmallBadge } from "@components/ui/SmallBadge";
import { cn } from "@utils/helpers";
import * as React from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import ControlCenterIcon from "@/assets/icons/ControlCenterIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import DocsIcon from "@/assets/icons/DocsIcon";
@@ -20,6 +17,10 @@ import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { headerHeight } from "@/layouts/Header";
import { NetworkNavigation } from "@/modules/networks/misc/NetworkNavigation";
import { SmallBadge } from "@components/ui/SmallBadge";
import * as React from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
type Props = {
fullWidth?: boolean;
@@ -131,6 +132,41 @@ export default function Navigation({
<NetworkNavigation />
<SidebarItem
icon={<ReverseProxyIcon size={16} />}
labelClassName={"pr-0"}
label={
<div className={"flex items-center gap-2"}>
Reverse Proxy
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[8px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</div>
}
href={"/reverse-proxy"}
collapsible
exactPathMatch={false}
visible={permission?.services?.read}
>
<SidebarItem
label="Services"
isChild
href={"/reverse-proxy/services"}
exactPathMatch={true}
visible={permission?.services?.read}
/>
<SidebarItem
label="Custom Domains"
isChild
href={"/reverse-proxy/custom-domains"}
exactPathMatch={true}
visible={permission?.services?.read}
/>
</SidebarItem>
<SidebarItem
icon={<DNSIcon />}
label="DNS"
@@ -176,13 +212,7 @@ export default function Navigation({
visible={permission.users.read}
/>
</SidebarItem>
<SidebarItem
icon={<ActivityIcon />}
label="Activity"
href={"/events/audit"}
exactPathMatch={true}
visible={permission.events.read}
/>
<ActivityNavigationItem />
</SidebarItemGroup>
<SidebarItemGroup>
@@ -225,3 +255,31 @@ export function SidebarItemGroup({ children }: SidebarItemGroupProps) {
</div>
);
}
const ActivityNavigationItem = () => {
const { permission } = usePermissions();
return (
<SidebarItem
icon={<ActivityIcon />}
label="Activity"
collapsible
visible={permission.events.read}
>
<SidebarItem
label="Audit Events"
href={"/events/audit"}
isChild
exactPathMatch={true}
visible={permission.events.read}
/>
<SidebarItem
label="Proxy Events"
isChild
href={"/events/proxy"}
exactPathMatch={true}
visible={permission.events.read}
/>
</SidebarItem>
);
};

View File

@@ -46,6 +46,7 @@ import React, { useMemo, useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy, PolicyRuleResource, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { useAccessControl } from "@/modules/access-control/useAccessControl";
@@ -54,6 +55,7 @@ import { PostureCheckTabTrigger } from "@/modules/posture-checks/ui/PostureCheck
import { SSHAccessType } from "@/modules/access-control/ssh/SSHAccessType";
import { SSHAuthorizedGroups } from "@/modules/access-control/ssh/SSHAuthorizedGroups";
import { useUsers } from "@/contexts/UsersProvider";
import { HelpTooltip } from "@components/HelpTooltip";
type Props = {
children?: React.ReactNode;
@@ -124,6 +126,8 @@ type ModalProps = {
initialPorts?: number[];
initialDestinationResource?: PolicyRuleResource;
initialTab?: string;
disableDestinationSelector?: boolean;
additionalResources?: NetworkResource[];
};
export function AccessControlModalContent({
@@ -140,6 +144,8 @@ export function AccessControlModalContent({
initialPorts,
initialDestinationResource,
initialTab,
disableDestinationSelector = false,
additionalResources,
}: Readonly<ModalProps>) {
const { permission } = usePermissions();
const { users } = useUsers();
@@ -293,7 +299,25 @@ export function AccessControlModalContent({
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="netbird-ssh">NetBird SSH</SelectItem>
<SelectItem
value="netbird-ssh"
extra={
<HelpTooltip
triggerClassName={"ml-[0.01rem]"}
align={"center"}
side={"right"}
content={
<>
Select NetBird SSH for SSH-specific policies with
fine-grained access control, or use TCP with port 22
for basic network-level SSH access
</>
}
/>
}
>
NetBird SSH
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -303,6 +327,15 @@ export function AccessControlModalContent({
<Label className={"mb-2"}>
<FolderDown size={15} />
Source
<HelpTooltip
content={
<>
Typically a group of user devices (e.g., Developers,
Marketing) or individual devices in peer-to-peer
connections that will access the destination.
</>
}
/>
</Label>
<PeerGroupSelector
dataCy={"source-group-selector"}
@@ -337,6 +370,15 @@ export function AccessControlModalContent({
<Label className={"mb-2"}>
<FolderInput size={15} />
Destination
<HelpTooltip
content={
<>
Typically a group of peers or resources (e.g., Servers,
Databases, Internal Services) that will be accessed by
the source. Can also be an individual peer or resource.
</>
}
/>
</Label>
<PeerGroupSelector
dataCy={"destination-group-selector"}
@@ -353,8 +395,11 @@ export function AccessControlModalContent({
resource={destinationResource}
onResourceChange={setDestinationResource}
saveGroupAssignments={useSave}
additionalResources={additionalResources}
disabled={
!permission.policies.update || !permission.policies.create
disableDestinationSelector ||
!permission.policies.update ||
!permission.policies.create
}
/>
</div>
@@ -575,7 +620,13 @@ export function AccessControlModalContent({
<Button
variant={"primary"}
disabled={submitDisabled || !permission.policies.create}
onClick={submit}
onClick={() => {
if (useSave) {
submit();
} else {
close();
}
}}
data-cy={"submit-policy"}
>
<PlusCircle size={16} />

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