Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7400ac806e | ||
|
|
240ff5af9a | ||
|
|
dc86c30463 | ||
|
|
e58f75ae3c | ||
|
|
dc1adebd27 | ||
|
|
d76cbd1122 | ||
|
|
01330e0f58 | ||
|
|
e9ac1a1a23 | ||
|
|
b53802a5c5 | ||
|
|
9addc18956 | ||
|
|
9701e6503b | ||
|
|
0841caecbb | ||
|
|
c7846760d1 | ||
|
|
8c283b6ef9 | ||
|
|
34ae3b4da6 | ||
|
|
aff2365ef7 | ||
|
|
bad057d415 | ||
|
|
4d846e2c94 | ||
|
|
15fb6e0b05 | ||
|
|
55c5525626 | ||
|
|
c0c1f4688e | ||
|
|
b5a8f751ba | ||
|
|
10a8e7b745 | ||
|
|
60e8394010 | ||
|
|
9420214059 | ||
|
|
b949f60afe | ||
|
|
d498e4cc25 | ||
|
|
130dc0c32c | ||
|
|
f5824d6ddb | ||
|
|
829395f908 | ||
|
|
8eebec78b4 | ||
|
|
3e01a6dafd | ||
|
|
1555b94043 | ||
|
|
6c62127d42 | ||
|
|
b71d0fde89 | ||
|
|
84c239ce30 | ||
|
|
ba66201c64 | ||
|
|
c6341e000f | ||
|
|
750f660bcc | ||
|
|
ea148545e8 | ||
|
|
d2febbf27b | ||
|
|
615b4487ad | ||
|
|
a7c7800916 | ||
|
|
3d51e0893e | ||
|
|
d7d44b5817 | ||
|
|
f67f39b68b | ||
|
|
d2bc7a1f57 | ||
|
|
818ba5daa4 | ||
|
|
3a30f76629 | ||
|
|
34dc21c89d | ||
|
|
2e37703622 | ||
|
|
8aec338c43 | ||
|
|
f4f0c240fd | ||
|
|
04e22a3c7e | ||
|
|
54ef076303 | ||
|
|
92676b6c38 | ||
|
|
3affa8908f | ||
|
|
52fd984912 | ||
|
|
83e3159ee4 | ||
|
|
bf81aeb02d | ||
|
|
b058e66e32 | ||
|
|
8d6b617cbd | ||
|
|
47db655e9f | ||
|
|
0661cbf9f4 | ||
|
|
240a96fa8b | ||
|
|
43bc069a49 |
12
.github/pull_request_template.md
vendored
Normal file
12
.github/pull_request_template.md
vendored
Normal 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/__
|
||||
14
.github/workflows/build_and_push.yml
vendored
14
.github/workflows/build_and_push.yml
vendored
@@ -2,7 +2,6 @@ name: build and push
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "feature/**"
|
||||
- main
|
||||
tags:
|
||||
- "**"
|
||||
@@ -20,7 +19,7 @@ jobs:
|
||||
- name: setup-node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -55,8 +54,19 @@ jobs:
|
||||
fileName: "ironrdp_web_bg.wasm"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=development" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
105
.github/workflows/docs-ack.yml
vendored
Normal file
105
.github/workflows/docs-ack.yml
vendored
Normal 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
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## Contributor License Agreement
|
||||
|
||||
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
|
||||
submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany,
|
||||
submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany,
|
||||
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
|
||||
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
|
||||
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance
|
||||
|
||||
19
README.md
19
README.md
@@ -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`
|
||||
|
||||
12
announcements.json
Normal file
12
announcements.json
Normal file
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"tag": "New",
|
||||
"text": "NetBird Reverse Proxy - Expose internal services to the public with automatic TLS and optional authentication.",
|
||||
"link": "https://docs.netbird.io/manage/reverse-proxy",
|
||||
"linkText": "Learn more",
|
||||
"variant": "important",
|
||||
"isExternal": true,
|
||||
"closeable": true,
|
||||
"isCloudOnly": false
|
||||
}
|
||||
]
|
||||
@@ -15,4 +15,4 @@
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
|
||||
"wasmPath": "$NETBIRD_WASM_PATH"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ const nextConfig = {
|
||||
reactStrictMode: false,
|
||||
env: {
|
||||
APP_ENV: process.env.APP_ENV || "production",
|
||||
NEXT_PUBLIC_DASHBOARD_VERSION:
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
9077
package-lock.json
generated
9077
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
79
package.json
79
package.json
@@ -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,50 +52,54 @@
|
||||
"chart.js": "^4.4.8",
|
||||
"chroma-js": "^3.1.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"flowbite": "^1.8.1",
|
||||
"flowbite-react": "^0.6.4",
|
||||
"framer-motion": "^10.16.4",
|
||||
"framer-motion": "^12.29.2",
|
||||
"ip-address": "^10.1.0",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^14.2.28",
|
||||
"lodash": "^4.17.23",
|
||||
"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-config-next": "^14.2.28",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3"
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@ import React from "react";
|
||||
|
||||
export default function Redirect() {
|
||||
useRedirect("/events/audit");
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import Button from "@components/Button";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import useFetchApi from "@utils/api";
|
||||
import {
|
||||
Background,
|
||||
@@ -15,6 +20,8 @@ import {
|
||||
NodeTypes,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { forEach, orderBy, sortBy } from "lodash";
|
||||
@@ -25,9 +32,23 @@ import {
|
||||
MessageSquareShareIcon,
|
||||
NetworkIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { AccessControlUpdateModal } from "@/modules/access-control/AccessControlModal";
|
||||
import { FlowSelector, FlowView } from "@/modules/control-center/FlowSelector";
|
||||
import { NetworkRoutingPeerCount } from "@/modules/control-center/NetworkRoutingPeerCount";
|
||||
import { ControlCenterCurrentUserBadge } from "@/modules/control-center/user/ControlCenterCurrentUserBadge";
|
||||
import { EDGE_TYPES } from "@/modules/control-center/utils/edges";
|
||||
import {
|
||||
getFirstGroup,
|
||||
@@ -41,24 +62,6 @@ import {
|
||||
DEFAULT_MIN_ZOOM,
|
||||
} from "@/modules/control-center/utils/layouts";
|
||||
import { NODE_TYPES } from "@/modules/control-center/utils/nodes";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { AccessControlUpdateModal } from "@/modules/access-control/AccessControlModal";
|
||||
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
|
||||
export default function ControlCenter() {
|
||||
return (
|
||||
@@ -71,8 +74,8 @@ export default function ControlCenter() {
|
||||
}
|
||||
|
||||
function ControlCenterView() {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [edges, setEdges] = useState<Edge[]>([]);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const reactFlow = useReactFlow();
|
||||
const [layoutInitialized, setLayoutInitialized] = useState(false);
|
||||
const [forceLayoutChange, setForceLayoutChange] = useState(false);
|
||||
@@ -82,6 +85,7 @@ function ControlCenterView() {
|
||||
const queryTab = queryParams.get("tab");
|
||||
const initialTab = useMemo(() => {
|
||||
if (queryTab === "peers") return FlowView.PEERS;
|
||||
if (queryTab === "users") return FlowView.USERS;
|
||||
if (queryTab === "groups") return FlowView.GROUPS;
|
||||
if (queryTab === "networks") return FlowView.NETWORKS;
|
||||
return FlowView.PEERS;
|
||||
@@ -99,17 +103,24 @@ function ControlCenterView() {
|
||||
>("/networks/resources");
|
||||
const { data: groups, isLoading: isGroupsLoading } =
|
||||
useFetchApi<Group[]>("/groups");
|
||||
const { data: users, isLoading: isUsersLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=false",
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
isPoliciesLoading ||
|
||||
isPeersLoading ||
|
||||
isNetworksLoading ||
|
||||
isResourcesLoading ||
|
||||
isGroupsLoading;
|
||||
isGroupsLoading ||
|
||||
isUsersLoading;
|
||||
|
||||
const [selectedNetwork, setSelectedNetwork] = useState("");
|
||||
const [selectedGroup, setSelectedGroup] = useState("");
|
||||
const [selectedPeer, setSelectedPeer] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState("");
|
||||
const [previousSelectedUser, setPreviousSelectedUser] = useState("");
|
||||
|
||||
const [selectedPolicy, setSelectedPolicy] = useState("");
|
||||
const [selectedDestinationGroup, setSelectedDestinationGroup] = useState("");
|
||||
|
||||
@@ -138,14 +149,149 @@ function ControlCenterView() {
|
||||
|
||||
const onDestinationGroupSelect = useCallback(
|
||||
(groupId: string) => {
|
||||
setLayoutInitialized(false);
|
||||
if (selectedDestinationGroup == groupId) {
|
||||
setSelectedDestinationGroup("");
|
||||
} else {
|
||||
setSelectedDestinationGroup(groupId);
|
||||
const isTogglingSameGroup = selectedDestinationGroup === groupId;
|
||||
const newSelectedGroup = isTogglingSameGroup ? "" : groupId;
|
||||
|
||||
setSelectedDestinationGroup(newSelectedGroup);
|
||||
|
||||
if (
|
||||
currentView !== FlowView.PEERS &&
|
||||
currentView !== FlowView.GROUPS &&
|
||||
currentView !== FlowView.USERS
|
||||
) {
|
||||
setLayoutInitialized(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const getPeersAndResources = (groupId: string) => {
|
||||
const resources =
|
||||
networkResources?.filter((n) => {
|
||||
const resourceGroupIds =
|
||||
n.groups?.map((g) => (g as Group)?.id) || [];
|
||||
return resourceGroupIds.includes(groupId);
|
||||
}) || [];
|
||||
|
||||
const groupPeers =
|
||||
peers?.filter((p) => {
|
||||
const peerGroupIds = p.groups?.map((g) => g.id) || [];
|
||||
return peerGroupIds.includes(groupId);
|
||||
}) || [];
|
||||
|
||||
return { resources, peers: groupPeers };
|
||||
};
|
||||
|
||||
const addExpandedNodes = (groupId: string, baseNodes: Node[]) => {
|
||||
const { resources, peers } = getPeersAndResources(groupId);
|
||||
const destinationGroupNode = baseNodes.find(
|
||||
(node) => node.id === `group-${groupId}`,
|
||||
);
|
||||
|
||||
if (!destinationGroupNode) return [];
|
||||
|
||||
const baseX = destinationGroupNode.position.x + 300;
|
||||
const groupCenterY = destinationGroupNode.position.y;
|
||||
const nodeSpacing = 80;
|
||||
const totalNodes = peers.length + resources.length;
|
||||
const totalHeight = (totalNodes - 1) * nodeSpacing;
|
||||
const startY = groupCenterY - totalHeight / 2;
|
||||
|
||||
const newNodes: Node[] = [];
|
||||
let currentY = startY;
|
||||
|
||||
// Add peer nodes
|
||||
peers.forEach((peer) => {
|
||||
newNodes.push({
|
||||
id: `peer-${peer.id}`,
|
||||
type:
|
||||
currentView === FlowView.PEERS ? "expandedGroupPeer" : "peerNode",
|
||||
data: { peer },
|
||||
position: { x: baseX, y: currentY },
|
||||
});
|
||||
currentY += nodeSpacing;
|
||||
});
|
||||
|
||||
// Add resource nodes
|
||||
resources.forEach((resource) => {
|
||||
newNodes.push({
|
||||
id: `resource-${resource.id}`,
|
||||
type: "resourceNode",
|
||||
data: { resource },
|
||||
position: { x: baseX, y: currentY },
|
||||
});
|
||||
currentY += nodeSpacing;
|
||||
});
|
||||
|
||||
return newNodes;
|
||||
};
|
||||
|
||||
const addExpandedEdges = (groupId: string) => {
|
||||
const { resources, peers } = getPeersAndResources(groupId);
|
||||
const newEdges: Edge[] = [];
|
||||
|
||||
// Add peer edges
|
||||
peers.forEach((peer) => {
|
||||
newEdges.push({
|
||||
id: `group-peer-${groupId}-${peer.id}`,
|
||||
source: `group-${groupId}`,
|
||||
target: `peer-${peer.id}`,
|
||||
type: "simple",
|
||||
data: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
// Add resource edges
|
||||
resources.forEach((resource) => {
|
||||
newEdges.push({
|
||||
id: `group-resource-${groupId}-${resource.id}`,
|
||||
source: `group-${groupId}`,
|
||||
target: `resource-${resource.id}`,
|
||||
type: "simple",
|
||||
data: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
return newEdges;
|
||||
};
|
||||
|
||||
// Update nodes
|
||||
setNodes((prevNodes) => {
|
||||
// Remove previous nodes
|
||||
const baseNodes = prevNodes.filter(
|
||||
(node) =>
|
||||
!node.id.startsWith(`peer-`) && !node.id.startsWith(`resource-`),
|
||||
);
|
||||
// If toggling a new group, add its nodes
|
||||
if (!isTogglingSameGroup) {
|
||||
const expandedNodes = addExpandedNodes(groupId, baseNodes);
|
||||
return [...baseNodes, ...expandedNodes];
|
||||
}
|
||||
return baseNodes;
|
||||
});
|
||||
|
||||
// Update edges
|
||||
setEdges((prevEdges) => {
|
||||
// Remove all previously expanded peer/resource edges
|
||||
const baseEdges = prevEdges.filter(
|
||||
(edge) =>
|
||||
!edge.id.includes(`group-peer-`) &&
|
||||
!edge.id.includes(`group-resource-`),
|
||||
);
|
||||
// If expanding a new group, add its edges
|
||||
if (!isTogglingSameGroup) {
|
||||
const expandedEdges = addExpandedEdges(groupId);
|
||||
return [...baseEdges, ...expandedEdges];
|
||||
}
|
||||
return baseEdges;
|
||||
});
|
||||
},
|
||||
[selectedDestinationGroup],
|
||||
[
|
||||
selectedDestinationGroup,
|
||||
currentView,
|
||||
setNodes,
|
||||
setEdges,
|
||||
networkResources,
|
||||
peers,
|
||||
],
|
||||
);
|
||||
|
||||
const applySingleGroupView = (groupId: string) => {
|
||||
@@ -211,7 +357,6 @@ function ControlCenterView() {
|
||||
type: "destinationGroupNode",
|
||||
data: {
|
||||
group: destination,
|
||||
enabled,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
@@ -235,7 +380,7 @@ function ControlCenterView() {
|
||||
allNodes.push({
|
||||
id: peerNodeId,
|
||||
type: "peerNode",
|
||||
data: { peer, enabled },
|
||||
data: { peer },
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
} else {
|
||||
@@ -281,7 +426,7 @@ function ControlCenterView() {
|
||||
allNodes.push({
|
||||
id: resourceNodeId,
|
||||
type: "resourceNode",
|
||||
data: { resource, enabled },
|
||||
data: { resource },
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
} else {
|
||||
@@ -356,6 +501,9 @@ function ControlCenterView() {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add destination resource nodes
|
||||
addDestinationResourceNodes(policy, allNodes, allEdges);
|
||||
});
|
||||
|
||||
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "group", {
|
||||
@@ -645,7 +793,6 @@ function ControlCenterView() {
|
||||
if (!groups || isGroupsLoading) return;
|
||||
if (!networks || isNetworksLoading) return;
|
||||
if (!networkResources || isResourcesLoading) return;
|
||||
if (layoutInitialized) return;
|
||||
|
||||
const allNodes: Node[] = [];
|
||||
const allEdges: Edge[] = [];
|
||||
@@ -704,7 +851,6 @@ function ControlCenterView() {
|
||||
type: "destinationGroupNode",
|
||||
data: {
|
||||
group: destination,
|
||||
enabled,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
@@ -848,6 +994,9 @@ function ControlCenterView() {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add destination resource nodes
|
||||
addDestinationResourceNodes(policy, allNodes, allEdges);
|
||||
});
|
||||
|
||||
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "peer", {
|
||||
@@ -857,13 +1006,290 @@ function ControlCenterView() {
|
||||
});
|
||||
};
|
||||
|
||||
const addDestinationResourceNodes = (
|
||||
policy: Policy,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
) => {
|
||||
const destinationPolicyResource = policy?.rules?.[0].destinationResource;
|
||||
const enabled = policy.enabled;
|
||||
|
||||
if (destinationPolicyResource) {
|
||||
const type = destinationPolicyResource.type;
|
||||
const peer = peers?.find((p) => p.id === destinationPolicyResource.id);
|
||||
const resource = networkResources?.find(
|
||||
(r) => r.id === destinationPolicyResource.id,
|
||||
);
|
||||
const nodeId = `destination-resource-${destinationPolicyResource.id}`;
|
||||
const nodeExists = nodes.some((n) => n.id === nodeId);
|
||||
if (!nodeExists) {
|
||||
if (type === "peer" && peer) {
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: "destinationResourceNode",
|
||||
data: {
|
||||
peer: peer,
|
||||
enabled,
|
||||
className: "pl-3",
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
} else if (resource) {
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: "destinationResourceNode",
|
||||
data: {
|
||||
resource: resource,
|
||||
enabled,
|
||||
className: "pl-3",
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
nodes.forEach((n) => {
|
||||
if (n.id === nodeId) {
|
||||
n.data = {
|
||||
...n.data,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const edgeExists = edges.some(
|
||||
(e) => e.id === `policy-dest-resource-${policy.id}-${nodeId}`,
|
||||
);
|
||||
if (!edgeExists) {
|
||||
edges.push({
|
||||
id: `policy-dest-resource-${policy.id}-${nodeId}`,
|
||||
source: `policy-${policy.id}`,
|
||||
target: nodeId,
|
||||
type: "in",
|
||||
data: { enabled, type: "bezier" },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyUserView = (userId: string) => {
|
||||
if (!policies || isLoading) return;
|
||||
if (!groups || isGroupsLoading) return;
|
||||
if (!networks || isNetworksLoading) return;
|
||||
if (!networkResources || isResourcesLoading) return;
|
||||
|
||||
const allNodes: Node[] = [];
|
||||
const allEdges: Edge[] = [];
|
||||
|
||||
// Get all peers for this user
|
||||
const userPeers = peers?.filter((p) => p.user_id === userId) || [];
|
||||
if (userPeers.length === 0) {
|
||||
return applyD3HierarchicalLayout([], [], 400, 120, "user", {
|
||||
policy: { width: 500, spacing: 60 },
|
||||
destinationGroup: { width: 1000, spacing: 100 },
|
||||
peersAndResources: { width: 1400, spacing: 80 },
|
||||
});
|
||||
}
|
||||
|
||||
// Add peer nodes
|
||||
userPeers.forEach((peer, index) => {
|
||||
allNodes.push({
|
||||
id: `source-peer-${peer.id}`,
|
||||
type: "sourcePeerNode",
|
||||
data: {
|
||||
peer,
|
||||
enabled: true,
|
||||
onClick: () => {
|
||||
setPreviousSelectedUser(userId);
|
||||
forceSinglePeerView(peer.id || "", userId);
|
||||
},
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
allEdges.push({
|
||||
id: `user-peer-${userId}-${peer.id}`,
|
||||
source: `select-user-node`,
|
||||
target: `source-peer-${peer.id}`,
|
||||
type: "simple",
|
||||
data: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
const allUserGroups = [
|
||||
...new Set(userPeers.flatMap((p) => p.groups?.map((g) => g.id) || [])),
|
||||
];
|
||||
const userPolicies = sortBy(
|
||||
policies?.filter((p) => {
|
||||
const rule = p.rules?.[0];
|
||||
if (!rule) return false;
|
||||
const sources = rule.sources as Group[];
|
||||
return sources?.some((d) => allUserGroups.includes(d.id));
|
||||
}),
|
||||
"enabled",
|
||||
"desc",
|
||||
);
|
||||
|
||||
// Add policies and their connections
|
||||
userPolicies?.forEach((policy, policyIndex) => {
|
||||
const enabled = policy.enabled;
|
||||
const policyNodeId = `policy-${policy.id}`;
|
||||
|
||||
allNodes.push({
|
||||
id: policyNodeId,
|
||||
type: "policyNode",
|
||||
data: { policy },
|
||||
position: { x: 600, y: policyIndex * 120 },
|
||||
});
|
||||
|
||||
// Add peer to policy edges
|
||||
const rule = policy.rules?.[0];
|
||||
const sourcesIds = (rule?.sources as Group[])?.map((g) => g.id) || [];
|
||||
|
||||
userPeers.forEach((peer) => {
|
||||
const peerGroupIds = peer.groups?.map((g) => g.id) || [];
|
||||
const hasSharedGroup = sourcesIds.some((sourceId) =>
|
||||
peerGroupIds.includes(sourceId),
|
||||
);
|
||||
|
||||
if (hasSharedGroup) {
|
||||
allEdges.push({
|
||||
id: `peer-policy-${peer.id}-${policy.id}`,
|
||||
source: `source-peer-${peer.id}`,
|
||||
target: policyNodeId,
|
||||
type: "in",
|
||||
data: { enabled, type: "bezier" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add destination groups
|
||||
const destinations = (rule?.destinations as Group[]) || [];
|
||||
destinations.forEach((destination, destIndex) => {
|
||||
const destinationNodeId = `group-${destination.id}`;
|
||||
const destinationNodeExists = allNodes.some(
|
||||
(n) => n.id === destinationNodeId,
|
||||
);
|
||||
|
||||
if (!destinationNodeExists) {
|
||||
allNodes.push({
|
||||
id: destinationNodeId,
|
||||
type: "destinationGroupNode",
|
||||
data: {
|
||||
group: destination,
|
||||
},
|
||||
position: { x: 900, y: policyIndex * 120 + destIndex * 60 },
|
||||
});
|
||||
}
|
||||
|
||||
const destinationEdgeExists = allEdges.some(
|
||||
(e) => e.id === `policy-group-${policy.id}-${destination.id}`,
|
||||
);
|
||||
if (!destinationEdgeExists) {
|
||||
allEdges.push({
|
||||
id: `policy-group-${policy.id}-${destination.id}`,
|
||||
source: policyNodeId,
|
||||
target: destinationNodeId,
|
||||
type: "in",
|
||||
data: { enabled, type: "bezier" },
|
||||
});
|
||||
}
|
||||
|
||||
// Add expanded destination group content if selected
|
||||
if (selectedDestinationGroup === destination.id) {
|
||||
const resources = networkResources.filter((n) => {
|
||||
const resourceGroupIds =
|
||||
n.groups?.map((g) => (g as Group)?.id) || [];
|
||||
return resourceGroupIds.includes(destination.id);
|
||||
});
|
||||
|
||||
const destinationPeers = peers?.filter((p) => {
|
||||
const peerGroupIds = p.groups?.map((g) => g.id) || [];
|
||||
return peerGroupIds.includes(destination.id);
|
||||
});
|
||||
|
||||
// Add peer nodes
|
||||
destinationPeers?.forEach((peer, peerIndex) => {
|
||||
const peerNodeId = `dest-peer-${peer.id}`;
|
||||
const peerNodeExists = allNodes.some((n) => n.id === peerNodeId);
|
||||
if (!peerNodeExists) {
|
||||
allNodes.push({
|
||||
id: peerNodeId,
|
||||
type: "peerNode",
|
||||
data: { peer },
|
||||
position: { x: 1200, y: policyIndex * 120 + peerIndex * 80 },
|
||||
});
|
||||
}
|
||||
|
||||
const peerEdgeExists = allEdges.some(
|
||||
(e) => e.id === `group-peer-${destination.id}-${peer.id}`,
|
||||
);
|
||||
if (!peerEdgeExists) {
|
||||
allEdges.push({
|
||||
id: `group-peer-${destination.id}-${peer.id}`,
|
||||
source: destinationNodeId,
|
||||
target: peerNodeId,
|
||||
type: "simple",
|
||||
data: { enabled },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add resource nodes
|
||||
resources.forEach((resource, resourceIndex) => {
|
||||
const resourceNodeId = `resource-${resource.id}`;
|
||||
const resourceNodeExists = allNodes.some(
|
||||
(n) => n.id === resourceNodeId,
|
||||
);
|
||||
if (!resourceNodeExists) {
|
||||
allNodes.push({
|
||||
id: resourceNodeId,
|
||||
type: "resourceNode",
|
||||
data: { resource },
|
||||
position: {
|
||||
x: 1200,
|
||||
y:
|
||||
policyIndex * 120 +
|
||||
(destinationPeers?.length || 0) * 80 +
|
||||
resourceIndex * 80,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const resourceEdgeExists = allEdges.some(
|
||||
(e) => e.id === `group-resource-${destination.id}-${resource.id}`,
|
||||
);
|
||||
if (!resourceEdgeExists) {
|
||||
allEdges.push({
|
||||
id: `group-resource-${destination.id}-${resource.id}`,
|
||||
source: destinationNodeId,
|
||||
target: resourceNodeId,
|
||||
type: "simple",
|
||||
data: { enabled },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add destination resource nodes
|
||||
addDestinationResourceNodes(policy, allNodes, allEdges);
|
||||
});
|
||||
|
||||
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "user", {
|
||||
policy: { width: 500, spacing: 60 },
|
||||
destinationGroup: { width: 1000, spacing: 100 },
|
||||
peersAndResources: { width: 1400, spacing: 80 },
|
||||
});
|
||||
};
|
||||
|
||||
const fitView = (newNodes?: Node[]) => {
|
||||
window.requestAnimationFrame(() =>
|
||||
reactFlow.fitView({
|
||||
nodes: newNodes ?? nodes,
|
||||
padding: 0.1,
|
||||
duration: 750,
|
||||
maxZoom: DEFAULT_MAX_ZOOM,
|
||||
maxZoom: 0.8,
|
||||
minZoom: DEFAULT_MIN_ZOOM,
|
||||
}),
|
||||
);
|
||||
@@ -903,6 +1329,76 @@ function ControlCenterView() {
|
||||
});
|
||||
};
|
||||
|
||||
const handlePeerChange = (newPeerId: string) => {
|
||||
setNodes((prev) => {
|
||||
const shouldRecalculate = selectedPeer !== newPeerId;
|
||||
shouldRecalculate && setSelectedPeer(newPeerId);
|
||||
|
||||
let selectPeerNode;
|
||||
const previousNodes = prev.map((node) => {
|
||||
if (node.id === `select-peer-node`) {
|
||||
selectPeerNode = shouldRecalculate
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
currentPeer: newPeerId,
|
||||
},
|
||||
}
|
||||
: node;
|
||||
return selectPeerNode;
|
||||
}
|
||||
return node;
|
||||
});
|
||||
const result = applyPeerView(newPeerId);
|
||||
if (result && selectPeerNode) {
|
||||
let nodesWithCurrentPeer = result.updatedNodes;
|
||||
nodesWithCurrentPeer.push(selectPeerNode);
|
||||
setEdges(result.updatedEdges);
|
||||
setLayoutInitialized(true);
|
||||
shouldRecalculate && fitView(nodesWithCurrentPeer);
|
||||
return nodesWithCurrentPeer;
|
||||
} else {
|
||||
return previousNodes;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUserChange = (newUserId: string) => {
|
||||
setNodes((prev) => {
|
||||
const shouldRecalculate = selectedUser !== newUserId;
|
||||
shouldRecalculate && setSelectedUser(newUserId);
|
||||
|
||||
let selectUserNode;
|
||||
const previousNodes = prev.map((node) => {
|
||||
if (node.id === `select-user-node`) {
|
||||
selectUserNode = shouldRecalculate
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
currentUser: newUserId,
|
||||
},
|
||||
}
|
||||
: node;
|
||||
return selectUserNode;
|
||||
}
|
||||
return node;
|
||||
});
|
||||
const result = applyUserView(newUserId);
|
||||
if (result && selectUserNode) {
|
||||
let nodesWithCurrentUser = result.updatedNodes;
|
||||
nodesWithCurrentUser.push(selectUserNode);
|
||||
setEdges(result.updatedEdges);
|
||||
setLayoutInitialized(true);
|
||||
shouldRecalculate && fitView(nodesWithCurrentUser);
|
||||
return nodesWithCurrentUser;
|
||||
} else {
|
||||
return previousNodes;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const forceSingleGroupView = (groupId: string) => {
|
||||
setSelectedGroup(groupId);
|
||||
setSelectedNetwork("");
|
||||
@@ -928,13 +1424,70 @@ function ControlCenterView() {
|
||||
}
|
||||
};
|
||||
|
||||
const forceSingleUserView = (userId: string) => {
|
||||
setSelectedPeer("");
|
||||
setSelectedUser("");
|
||||
setPreviousSelectedUser("");
|
||||
setCurrentView(FlowView.USERS);
|
||||
|
||||
const selectUserNode = {
|
||||
id: `select-user-node`,
|
||||
type: "selectUserNode",
|
||||
position: { x: -550, y: 0 },
|
||||
data: {
|
||||
currentUser: userId,
|
||||
onUserChange: handleUserChange,
|
||||
},
|
||||
};
|
||||
|
||||
setNodes([selectUserNode]);
|
||||
|
||||
const result = applyUserView(userId);
|
||||
if (result) {
|
||||
let nodesWithUser = result.updatedNodes;
|
||||
nodesWithUser.push(selectUserNode);
|
||||
setEdges(result.updatedEdges);
|
||||
setNodes(nodesWithUser);
|
||||
setLayoutInitialized(true);
|
||||
fitView(nodesWithUser);
|
||||
}
|
||||
};
|
||||
|
||||
const forceSinglePeerView = (peerId: string, userId?: string) => {
|
||||
setSelectedPeer(peerId);
|
||||
setSelectedNetwork("");
|
||||
setSelectedUser("");
|
||||
setCurrentView(FlowView.PEERS);
|
||||
const selectPeerNode = {
|
||||
id: `select-peer-node`,
|
||||
type: "selectPeerNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
currentPeer: peerId,
|
||||
onPeerChange: handlePeerChange,
|
||||
userId: userId,
|
||||
placeholder: "Search peers of user...",
|
||||
},
|
||||
};
|
||||
setNodes([selectPeerNode]);
|
||||
const result = applyPeerView(peerId);
|
||||
if (result) {
|
||||
let nodesWithCurrentPeer = result.updatedNodes;
|
||||
nodesWithCurrentPeer.push(selectPeerNode);
|
||||
setEdges(result.updatedEdges);
|
||||
setNodes(nodesWithCurrentPeer);
|
||||
setLayoutInitialized(true);
|
||||
fitView(nodesWithCurrentPeer);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (layoutInitialized) return;
|
||||
|
||||
switch (currentView) {
|
||||
case FlowView.PEERS:
|
||||
if (peers?.length === 0) {
|
||||
if (!peers || peers.length === 0) {
|
||||
setEdges([]);
|
||||
setNodes([]);
|
||||
setLayoutInitialized(true);
|
||||
@@ -942,41 +1495,6 @@ function ControlCenterView() {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePeerChange = (newPeerId: string) => {
|
||||
setNodes((prev) => {
|
||||
const shouldRecalculate = selectedPeer !== newPeerId;
|
||||
shouldRecalculate && setSelectedPeer(newPeerId);
|
||||
|
||||
let selectPeerNode;
|
||||
const previousNodes = prev.map((node) => {
|
||||
if (node.id === `select-peer-node`) {
|
||||
selectPeerNode = shouldRecalculate
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
currentPeer: newPeerId,
|
||||
},
|
||||
}
|
||||
: node;
|
||||
return selectPeerNode;
|
||||
}
|
||||
return node;
|
||||
});
|
||||
const result = applyPeerView(newPeerId);
|
||||
if (result && selectPeerNode) {
|
||||
let nodesWithCurrentPeer = result.updatedNodes;
|
||||
nodesWithCurrentPeer.push(selectPeerNode);
|
||||
setEdges(result.updatedEdges);
|
||||
setLayoutInitialized(true);
|
||||
shouldRecalculate && fitView(nodesWithCurrentPeer);
|
||||
return nodesWithCurrentPeer;
|
||||
} else {
|
||||
return previousNodes;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedPeer === "") {
|
||||
const userPeer = peers?.find((p) => p.user_id === loggedInUser?.id);
|
||||
const firstPeer = userPeer ?? peers?.[0];
|
||||
@@ -998,6 +1516,50 @@ function ControlCenterView() {
|
||||
handlePeerChange(selectedPeer);
|
||||
}
|
||||
|
||||
break;
|
||||
case FlowView.USERS:
|
||||
if (!users || users.length === 0) {
|
||||
setEdges([]);
|
||||
setNodes([]);
|
||||
setLayoutInitialized(true);
|
||||
fitView([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedUser === "") {
|
||||
let initialUser = users?.find((u) => u.id === loggedInUser?.id);
|
||||
|
||||
if (
|
||||
!initialUser ||
|
||||
!peers?.some((p) => p.user_id === initialUser?.id)
|
||||
) {
|
||||
initialUser = users?.find(
|
||||
(u) => peers?.some((p) => p.user_id === u.id),
|
||||
);
|
||||
}
|
||||
|
||||
if (!initialUser) {
|
||||
initialUser = users?.[0];
|
||||
}
|
||||
|
||||
const initialUserId = initialUser?.id ?? "";
|
||||
setNodes([
|
||||
{
|
||||
id: `select-user-node`,
|
||||
type: "selectUserNode",
|
||||
position: { x: -550, y: 0 },
|
||||
data: {
|
||||
currentUser: initialUserId,
|
||||
onUserChange: handleUserChange,
|
||||
},
|
||||
},
|
||||
]);
|
||||
if (initialUserId !== "") handleUserChange(initialUserId);
|
||||
} else {
|
||||
resetView();
|
||||
handleUserChange(selectedUser);
|
||||
}
|
||||
|
||||
break;
|
||||
case FlowView.GROUPS:
|
||||
if (selectedGroup === "") {
|
||||
@@ -1023,7 +1585,7 @@ function ControlCenterView() {
|
||||
}
|
||||
break;
|
||||
case FlowView.NETWORKS:
|
||||
if (networks?.length === 0) {
|
||||
if (!networks || networks.length === 0) {
|
||||
setEdges([]);
|
||||
setNodes([]);
|
||||
setLayoutInitialized(true);
|
||||
@@ -1051,6 +1613,7 @@ function ControlCenterView() {
|
||||
selectedNetwork,
|
||||
selectedPeer,
|
||||
selectedGroup,
|
||||
selectedUser,
|
||||
isLoading,
|
||||
layoutInitialized,
|
||||
]);
|
||||
@@ -1077,6 +1640,7 @@ function ControlCenterView() {
|
||||
setSelectedPeer("");
|
||||
setSelectedGroup("");
|
||||
setSelectedNetwork("");
|
||||
setSelectedUser("");
|
||||
setCurrentView(view);
|
||||
|
||||
try {
|
||||
@@ -1108,7 +1672,11 @@ function ControlCenterView() {
|
||||
if (networkId && currentView === FlowView.NETWORKS) {
|
||||
onNetworkSelect(networkId);
|
||||
}
|
||||
if (currentView === FlowView.PEERS || currentView === FlowView.GROUPS) {
|
||||
if (
|
||||
currentView === FlowView.PEERS ||
|
||||
currentView === FlowView.GROUPS ||
|
||||
currentView === FlowView.USERS
|
||||
) {
|
||||
groupId && onGroupSelect(groupId);
|
||||
destinationGroupId && onDestinationGroupSelect(destinationGroupId);
|
||||
}
|
||||
@@ -1206,10 +1774,6 @@ function ControlCenterView() {
|
||||
<div className={"absolute left-0 top-0 z-10"}>
|
||||
<div className={"flex justify-between px-6 py-4 text-sm w-full"}>
|
||||
<div className={"flex gap-4"}>
|
||||
{selectedNetwork === "" && (
|
||||
<FlowSelector value={currentView} onChange={onViewChange} />
|
||||
)}
|
||||
|
||||
{selectedNetwork !== "" && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
@@ -1221,6 +1785,28 @@ function ControlCenterView() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{previousSelectedUser !== "" && (
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
className={"!bg-nb-gray-930"}
|
||||
onClick={() => {
|
||||
forceSingleUserView(previousSelectedUser);
|
||||
}}
|
||||
>
|
||||
<ArrowLeftIcon size={14} />
|
||||
</Button>
|
||||
<ControlCenterCurrentUserBadge
|
||||
userId={previousSelectedUser}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedNetwork === "" && previousSelectedUser === "" && (
|
||||
<FlowSelector value={currentView} onChange={onViewChange} />
|
||||
)}
|
||||
|
||||
{currentView === "networks" && (
|
||||
<div className={"w-64"}>
|
||||
<SelectDropdown
|
||||
@@ -1270,6 +1856,8 @@ function ControlCenterView() {
|
||||
<ReactFlow
|
||||
edges={edges}
|
||||
nodes={nodes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
proOptions={{
|
||||
hideAttribution: true,
|
||||
}}
|
||||
|
||||
@@ -7,7 +7,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon, ServerIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -15,7 +15,7 @@ import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const NameserverGroupTable = lazy(
|
||||
() => import("@/modules/dns-nameservers/table/NameserverGroupTable"),
|
||||
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
|
||||
);
|
||||
|
||||
export default function NameServers() {
|
||||
@@ -40,7 +40,7 @@ export default function NameServers() {
|
||||
href={"/dns/nameservers"}
|
||||
label={"Nameservers"}
|
||||
active
|
||||
icon={<ServerIcon size={13} />}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Nameservers</h1>
|
||||
|
||||
@@ -11,5 +11,5 @@ export default function DNS() {
|
||||
router.push("/dns/nameservers");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
8
src/app/(dashboard)/dns/zones/layout.tsx
Normal file
8
src/app/(dashboard)/dns/zones/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Zones - DNS - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
70
src/app/(dashboard)/dns/zones/page.tsx
Normal file
70
src/app/(dashboard)/dns/zones/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
|
||||
const DNSZonesTable = lazy(
|
||||
() => import("@/modules/dns/zones/table/DNSZonesTable"),
|
||||
);
|
||||
|
||||
export default function DNSZonePage() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: zones, isLoading } = useFetchApi<DNSZone[]>("/dns/zones");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item label={"DNS"} icon={<DNSIcon size={13} />} />
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/zones"}
|
||||
label={"Zones"}
|
||||
active
|
||||
icon={<DNSZoneIcon size={16} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Zones</h1>
|
||||
<Paragraph>
|
||||
Manage DNS zones to control domain name resolution for your network.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
|
||||
DNS Zones
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess page={"DNS Zones"} hasAccess={permission?.dns?.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<DNSZonesProvider>
|
||||
<DNSZonesTable
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
data={zones}
|
||||
/>
|
||||
</DNSZonesProvider>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
80
src/app/(dashboard)/events/proxy/page.tsx
Normal file
80
src/app/(dashboard)/events/proxy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
@@ -24,6 +25,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { GroupDNSZonesSection } from "@/modules/groups/details/GroupDNSZonesSection";
|
||||
import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection";
|
||||
import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection";
|
||||
import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection";
|
||||
@@ -134,7 +136,9 @@ const validAllGroupTabs = [
|
||||
"resources",
|
||||
"network-routes",
|
||||
"nameservers",
|
||||
"zones",
|
||||
];
|
||||
|
||||
const validOtherGroupTabs = ["users", "peers", "setup-keys"];
|
||||
|
||||
const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
@@ -154,7 +158,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
};
|
||||
|
||||
const [tab, setTab] = useState(getInitialTab());
|
||||
const groupDetails = useGroupDetails(group?.id || "");
|
||||
const { groupDetails, isLoading } = useGroupDetails(group?.id || "");
|
||||
|
||||
const peersCount = groupDetails?.peers_count || 0;
|
||||
const usersCount = groupDetails?.users?.length || 0;
|
||||
@@ -162,6 +166,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
const resourcesCount = groupDetails?.resources_count || 0;
|
||||
const routesCount = groupDetails?.routes?.length || 0;
|
||||
const nameserversCount = groupDetails?.nameservers?.length || 0;
|
||||
const zonesCount = groupDetails?.zones?.length || 0;
|
||||
const setupKeysCount = groupDetails?.setupKeys?.length || 0;
|
||||
|
||||
return (
|
||||
@@ -249,6 +254,19 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
{singularize("Nameservers", nameserversCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value={"zones"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<DNSZoneIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Zones", zonesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
{group.name !== "All" && (
|
||||
<TabsTrigger
|
||||
value={"setup-keys"}
|
||||
@@ -266,31 +284,56 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={"users"} className={"pb-8"}>
|
||||
<GroupUsersSection users={groupDetails?.users} />
|
||||
<GroupUsersSection users={groupDetails?.users} isLoading={isLoading} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"peers"} className={"pb-8"}>
|
||||
<GroupPeersSection peers={groupDetails?.peersOfGroup} />
|
||||
<GroupPeersSection
|
||||
peers={groupDetails?.peersOfGroup}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"policies"} className={"pb-8"}>
|
||||
<GroupPoliciesSection policies={groupDetails?.policies} />
|
||||
<GroupPoliciesSection
|
||||
policies={groupDetails?.policies}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<GroupResourcesSection resources={groupDetails?.networkResources} />
|
||||
<GroupResourcesSection
|
||||
resources={groupDetails?.networkResources}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"network-routes"} className={"pb-8"}>
|
||||
<GroupNetworkRoutesSection routes={groupDetails?.routes} />
|
||||
<GroupNetworkRoutesSection
|
||||
routes={groupDetails?.routes}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"nameservers"} className={"pb-8"}>
|
||||
<GroupNameserversSection nameserverGroups={groupDetails?.nameservers} />
|
||||
<GroupNameserversSection
|
||||
nameserverGroups={groupDetails?.nameservers}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"zones"} className={"pb-8"}>
|
||||
<GroupDNSZonesSection
|
||||
zones={groupDetails?.zones}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"setup-keys"} className={"pb-8"}>
|
||||
<GroupSetupKeysSection setupKeys={groupDetails?.setupKeys} />
|
||||
<GroupSetupKeysSection
|
||||
setupKeys={groupDetails?.setupKeys}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import { Callout } from "@components/Callout";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
@@ -17,6 +16,7 @@ import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
|
||||
import { Callout } from "@components/Callout";
|
||||
|
||||
const NetworkRoutesTable = lazy(
|
||||
() => import("@/modules/route-group/NetworkRoutesTable"),
|
||||
@@ -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.{" "}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import Card from "@components/Card";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
@@ -26,22 +25,25 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { singularize } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
Barcode,
|
||||
CalendarDays,
|
||||
Cpu,
|
||||
FlagIcon,
|
||||
Globe,
|
||||
History,
|
||||
ListIcon,
|
||||
MapPin,
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
TimerResetIcon,
|
||||
RadioTowerIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toASCII } from "punycode";
|
||||
import React, { useMemo, useState } from "react";
|
||||
@@ -51,20 +53,29 @@ import RoundedFlag from "@/assets/countries/RoundedFlag";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { useCountries } from "@/contexts/CountryProvider";
|
||||
import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||
import type { Group } from "@/interfaces/Group";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
|
||||
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
|
||||
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
|
||||
import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection";
|
||||
import ReverseProxiesProvider, {
|
||||
flattenReverseProxies,
|
||||
useReverseProxies,
|
||||
} from "@/contexts/ReverseProxiesProvider";
|
||||
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
|
||||
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
|
||||
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
|
||||
|
||||
export default function PeerPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -78,12 +89,6 @@ export default function PeerPage() {
|
||||
|
||||
useRedirect("/peers", false, !peerId || isRestricted);
|
||||
|
||||
const peerKey = useMemo(() => {
|
||||
let id = peer?.id ?? "";
|
||||
let expiration = peer?.login_expiration_enabled ? "1" : "0";
|
||||
return `${id}-${expiration}`;
|
||||
}, [peer]);
|
||||
|
||||
if (isRestricted) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -102,10 +107,12 @@ export default function PeerPage() {
|
||||
/>
|
||||
);
|
||||
|
||||
return peer && !isLoading ? (
|
||||
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
|
||||
<PeerOverview key={peerKey} />
|
||||
</PeerProvider>
|
||||
return peer && peer.id && !isLoading ? (
|
||||
<ReverseProxiesProvider initialPeer={peer}>
|
||||
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
|
||||
<PeerOverview key={peer?.id} />
|
||||
</PeerProvider>
|
||||
</ReverseProxiesProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
);
|
||||
@@ -117,48 +124,62 @@ function PeerOverview() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<RoutesProvider>
|
||||
<div className={"p-default py-6 pb-0"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers"}
|
||||
label={"Peers"}
|
||||
icon={<PeerIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item label={peer.ip} active />
|
||||
</Breadcrumbs>
|
||||
<PeerGeneralInformation />
|
||||
</div>
|
||||
<PeerOverviewTabs />
|
||||
<PeerSettingsProvider>
|
||||
<div className={"p-default py-6 pb-0"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers"}
|
||||
label={"Peers"}
|
||||
icon={<PeerIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item label={peer.ip} active />
|
||||
</Breadcrumbs>
|
||||
<PeerHeader />
|
||||
</div>
|
||||
<PeerOverviewTabs />
|
||||
</PeerSettingsProvider>
|
||||
</RoutesProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const PeerGeneralInformation = () => {
|
||||
const router = useRouter();
|
||||
type PeerSettingsContextType = {
|
||||
selectedGroups: Group[];
|
||||
setSelectedGroups: React.Dispatch<React.SetStateAction<Group[]>>;
|
||||
hasChanges: boolean;
|
||||
updatePeer: (newName?: string) => Promise<void>;
|
||||
name: string;
|
||||
setName: (name: string) => void;
|
||||
tab: string;
|
||||
setTab: (tab: string) => void;
|
||||
};
|
||||
|
||||
const PeerSettingsContext = React.createContext<PeerSettingsContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const usePeerSettings = () => {
|
||||
const context = React.useContext(PeerSettingsContext);
|
||||
if (!context) {
|
||||
throw new Error("usePeerSettings must be used within PeerSettingsProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { peer, user, peerGroups, update } = usePeer();
|
||||
const { peer, peerGroups, update } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const [name, setName] = useState(peer.name);
|
||||
const [showEditNameModal, setShowEditNameModal] = useState(false);
|
||||
const [loginExpiration, setLoginExpiration] = useState(
|
||||
peer.login_expiration_enabled,
|
||||
);
|
||||
const [inactivityExpiration, setInactivityExpiration] = useState(
|
||||
peer.inactivity_expiration_enabled,
|
||||
);
|
||||
const [tab, setTab] = useState("overview");
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
initial: peerGroups,
|
||||
initial: peerGroups?.filter((g) => g?.name !== "All"),
|
||||
peer,
|
||||
});
|
||||
|
||||
/**
|
||||
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
|
||||
*/
|
||||
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
|
||||
const updatePeer = async (newName?: string) => {
|
||||
@@ -168,8 +189,6 @@ const PeerGeneralInformation = () => {
|
||||
if (permission.peers.update) {
|
||||
const updateRequest = update({
|
||||
name: newName ?? name,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
});
|
||||
batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest];
|
||||
} else {
|
||||
@@ -182,17 +201,37 @@ const PeerGeneralInformation = () => {
|
||||
promise: Promise.all(batchCall).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
mutate("/groups");
|
||||
updateHasChangedRef([
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
updateHasChangedRef([selectedGroups]);
|
||||
}),
|
||||
loadingMessage: "Saving the peer...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PeerSettingsContext.Provider
|
||||
value={{
|
||||
selectedGroups,
|
||||
setSelectedGroups,
|
||||
hasChanges,
|
||||
updatePeer,
|
||||
name,
|
||||
setName,
|
||||
tab,
|
||||
setTab,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PeerSettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerHeader = () => {
|
||||
const router = useRouter();
|
||||
const { peer, user } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const { name, setName, hasChanges, updatePeer, tab } = usePeerSettings();
|
||||
const [showEditNameModal, setShowEditNameModal] = useState(false);
|
||||
const isOverviewTab = tab === "overview";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -237,87 +276,161 @@ const PeerGeneralInformation = () => {
|
||||
</h1>
|
||||
<LoginExpiredBadge loginExpired={peer.login_expired} />
|
||||
</div>
|
||||
<div className={"flex items-center gap-8"}>
|
||||
<Paragraph className={"flex items-center"}>{user?.email}</Paragraph>
|
||||
{(user?.id || user?.email) && (
|
||||
<div className={"flex items-center gap-8"}>
|
||||
<Paragraph className={"flex items-center"}>
|
||||
<Link
|
||||
href={`/team/user?id=${user?.id}`}
|
||||
className={
|
||||
"hover:text-nb-gray-200 transition-all flex items-center gap-1"
|
||||
}
|
||||
>
|
||||
{user?.email || user?.id}
|
||||
<ArrowRightIcon size={14} />
|
||||
</Link>
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isOverviewTab && (
|
||||
<div className={"flex gap-4"}>
|
||||
<Button
|
||||
variant={"default"}
|
||||
className={"w-full"}
|
||||
onClick={() => router.push("/peers")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => updatePeer()}
|
||||
disabled={
|
||||
!hasChanges ||
|
||||
!permission.peers.update ||
|
||||
!permission.groups.update
|
||||
}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex gap-4"}>
|
||||
<Button
|
||||
variant={"default"}
|
||||
className={"w-full"}
|
||||
onClick={() => router.push("/peers")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => updatePeer()}
|
||||
disabled={
|
||||
!hasChanges || !permission.peers.read || !permission.groups.update
|
||||
}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerOverviewTabs = () => {
|
||||
const { peer } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies();
|
||||
const { tab, setTab } = usePeerSettings();
|
||||
|
||||
const flatTargets = useMemo(
|
||||
() => flattenReverseProxies({ reverseProxies, peer }),
|
||||
[reverseProxies, peer],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pt-4 pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"overview"}>
|
||||
<ListIcon size={16} />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
|
||||
{permission.routes.read && (
|
||||
<TabsTrigger value={"network-routes"}>
|
||||
<NetworkIcon size={16} />
|
||||
Network Routes
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.read && (
|
||||
<TabsTrigger value={"accessible-peers"}>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
Accessible Peers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.services?.read && (
|
||||
<TabsTrigger value={"reverse-proxies"}>
|
||||
<ReverseProxyIcon
|
||||
size={16}
|
||||
className="fill-nb-gray-400 group-data-[state=active]/trigger:fill-netbird"
|
||||
/>
|
||||
{singularize("Services", flatTargets.length)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.delete && (
|
||||
<TabsTrigger value={"peer-job"}>
|
||||
<RadioTowerIcon size={16} />
|
||||
Remote Jobs
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={"overview"} className={"pb-8"}>
|
||||
<PeerOverviewTabContent />
|
||||
</TabsContent>
|
||||
|
||||
{permission.routes.read && (
|
||||
<TabsContent value={"network-routes"} className={"pb-8"}>
|
||||
<PeerNetworkRoutesSection peer={peer} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.read && (
|
||||
<TabsContent value={"accessible-peers"} className={"pb-8"}>
|
||||
<AccessiblePeersSection peerID={peer.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.services?.read && (
|
||||
<TabsContent value={"reverse-proxies"} className={"pb-8"}>
|
||||
<ReverseProxyFlatTargetsTabContent
|
||||
targets={flatTargets}
|
||||
isLoading={isServicesLoading}
|
||||
hideResourceColumn
|
||||
emptyTableTitle={"This peer has no services"}
|
||||
emptyTableDescription={
|
||||
"Add your services to this peer and securely expose them through NetBird's reverse proxy"
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{peer.id && permission.peers.delete && (
|
||||
<TabsContent value={"peer-job"} className={"pb-8"}>
|
||||
<PeerRemoteJobsSection peerID={peer.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerOverviewTabContent = () => {
|
||||
const { peer } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const { selectedGroups, setSelectedGroups } = usePeerSettings();
|
||||
|
||||
return (
|
||||
<div className={"px-8"}>
|
||||
<div
|
||||
className={
|
||||
"flex-wrap xl:flex-nowrap flex gap-10 w-full mt-5 max-w-6xl items-start"
|
||||
"flex-wrap xl:flex-nowrap flex gap-10 w-full items-start pt-2 max-w-6xl"
|
||||
}
|
||||
>
|
||||
<PeerInformationCard peer={peer} />
|
||||
|
||||
<div className={"flex flex-col gap-6 lg:w-1/2 transition-all"}>
|
||||
<div>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
value={loginExpiration}
|
||||
icon={<TimerResetIcon size={16} />}
|
||||
onChange={(state) => {
|
||||
setLoginExpiration(state);
|
||||
!state && setInactivityExpiration(false);
|
||||
}}
|
||||
/>
|
||||
{permission.peers.update && !!peer?.user_id && (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
|
||||
!loginExpiration
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "bg-nb-gray-930/80",
|
||||
)}
|
||||
>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
variant={"blank"}
|
||||
value={inactivityExpiration}
|
||||
onChange={setInactivityExpiration}
|
||||
title={"Require login after disconnect"}
|
||||
description={
|
||||
"Enable to require authentication after users disconnect from management for 10 minutes."
|
||||
}
|
||||
className={
|
||||
!loginExpiration ? "opacity-40 pointer-events-none" : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 className={"flex flex-col gap-8 lg:w-1/2 transition-all"}>
|
||||
<PeerExpirationSettings />
|
||||
{permission.groups.read && (
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
@@ -333,55 +446,21 @@ const PeerGeneralInformation = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PeerSSHToggle />
|
||||
|
||||
{/* Remote Access Buttons */}
|
||||
<div>
|
||||
<Label>Remote Access</Label>
|
||||
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
|
||||
<div className="flex gap-3">
|
||||
<SSHButton peer={peer} />
|
||||
<RDPButton peer={peer} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerOverviewTabs = () => {
|
||||
const { peer } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const [tab, setTab] = useState(
|
||||
permission.routes.read ? "network-routes" : "accessible-peers",
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={(v) => setTab(v)}
|
||||
value={tab}
|
||||
className={"pt-10 pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
{permission.routes.read && (
|
||||
<TabsTrigger value={"network-routes"}>
|
||||
<NetworkIcon size={16} />
|
||||
Network Routes
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.read && (
|
||||
<TabsTrigger value={"accessible-peers"}>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
Accessible Peers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{permission.routes.read && (
|
||||
<TabsContent value={"network-routes"} className={"pb-8"}>
|
||||
<PeerNetworkRoutesSection peer={peer} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.read && (
|
||||
<TabsContent value={"accessible-peers"} className={"pb-8"}>
|
||||
<AccessiblePeersSection peerID={peer.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -390,31 +469,55 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
const { update } = usePeer();
|
||||
const { mutate } = useSWRConfig();
|
||||
const [showEditIPModal, setShowEditIPModal] = useState(false);
|
||||
const [showEditIPv6Modal, setShowEditIPv6Modal] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const countryText = useMemo(() => {
|
||||
return getRegionByPeer(peer);
|
||||
}, [getRegionByPeer, peer]);
|
||||
|
||||
const handleSaveIP = (newIP: string) => {
|
||||
notify({
|
||||
title: peer.name,
|
||||
description: "NetBird Peer IP was successfully updated",
|
||||
promise: update({ ip: newIP }).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
setShowEditIPModal(false);
|
||||
}),
|
||||
loadingMessage: "Updating peer IP...",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveIPv6 = (newIPv6: string) => {
|
||||
notify({
|
||||
title: peer.name,
|
||||
description: "NetBird Peer IPv6 was successfully updated",
|
||||
promise: update({ ipv6: newIPv6 }).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
setShowEditIPv6Modal(false);
|
||||
}),
|
||||
loadingMessage: "Updating peer IPv6...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={showEditIPModal} onOpenChange={setShowEditIPModal}>
|
||||
<EditIPModal
|
||||
onSuccess={(newIP) => {
|
||||
notify({
|
||||
title: peer.name,
|
||||
description: "Peer IP was successfully updated",
|
||||
promise: update({ ip: newIP }).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
setShowEditIPModal(false);
|
||||
}),
|
||||
loadingMessage: "Updating peer IP...",
|
||||
});
|
||||
}}
|
||||
peer={peer}
|
||||
key={showEditIPModal ? 1 : 0}
|
||||
/>
|
||||
</Modal>
|
||||
<PeerEditIPModal
|
||||
version="v4"
|
||||
currentIP={peer.ip}
|
||||
open={showEditIPModal}
|
||||
onOpenChange={setShowEditIPModal}
|
||||
onSave={handleSaveIP}
|
||||
key={showEditIPModal ? "v4-open" : "v4-closed"}
|
||||
/>
|
||||
<PeerEditIPModal
|
||||
version="v6"
|
||||
currentIP={peer.ipv6 || ""}
|
||||
open={showEditIPv6Modal}
|
||||
onOpenChange={setShowEditIPv6Modal}
|
||||
onSave={handleSaveIPv6}
|
||||
key={showEditIPv6Modal ? "v6-open" : "v6-closed"}
|
||||
/>
|
||||
<Card className={"w-full xl:w-1/2"}>
|
||||
<Card.List>
|
||||
<Card.ListItem
|
||||
@@ -423,35 +526,48 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
copyText={"NetBird IP Address"}
|
||||
label={
|
||||
<>
|
||||
<MapPin size={16} />
|
||||
<MapPin size={16} className={"shrink-0"} />
|
||||
NetBird IP Address
|
||||
</>
|
||||
}
|
||||
valueToCopy={peer.ip}
|
||||
value={
|
||||
<div className="flex items-center gap-2 justify-between w-full">
|
||||
<span>{peer.ip}</span>
|
||||
{permission.peers.update && (
|
||||
<button
|
||||
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowEditIPModal(true);
|
||||
}}
|
||||
>
|
||||
<PencilIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<EditableValue
|
||||
value={peer.ip}
|
||||
canEdit={permission.peers.update}
|
||||
onEdit={() => setShowEditIPModal(true)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{peer.ipv6 && (
|
||||
<Card.ListItem
|
||||
copy
|
||||
tooltip={false}
|
||||
copyText={"NetBird IPv6 Address"}
|
||||
label={
|
||||
<>
|
||||
<MapPin size={16} className={"shrink-0"} />
|
||||
NetBird IPv6 Address
|
||||
</>
|
||||
}
|
||||
valueToCopy={peer.ipv6}
|
||||
value={
|
||||
<EditableValue
|
||||
value={peer.ipv6}
|
||||
canEdit={permission.peers.update}
|
||||
onEdit={() => setShowEditIPv6Modal(true)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card.ListItem
|
||||
copy
|
||||
copyText={"Public IP Address"}
|
||||
label={
|
||||
<>
|
||||
<NetworkIcon size={16} />
|
||||
<NetworkIcon size={16} className={"shrink-0"} />
|
||||
Public IP Address
|
||||
</>
|
||||
}
|
||||
@@ -463,7 +579,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
copyText={"DNS label"}
|
||||
label={
|
||||
<>
|
||||
<Globe size={16} />
|
||||
<Globe size={16} className={"shrink-0"} />
|
||||
Domain Name
|
||||
</>
|
||||
}
|
||||
@@ -481,7 +597,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
copyText={"Hostname"}
|
||||
label={
|
||||
<>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
<MonitorSmartphoneIcon size={16} className={"shrink-0"} />
|
||||
Hostname
|
||||
</>
|
||||
}
|
||||
@@ -491,7 +607,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<FlagIcon size={16} />
|
||||
<FlagIcon size={16} className={"shrink-0"} />
|
||||
Region
|
||||
</>
|
||||
}
|
||||
@@ -521,7 +637,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Cpu size={16} />
|
||||
<Cpu size={16} className={"shrink-0"} />
|
||||
Operating System
|
||||
</>
|
||||
}
|
||||
@@ -532,7 +648,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Barcode size={16} />
|
||||
<Barcode size={16} className={"shrink-0"} />
|
||||
Serial Number
|
||||
</>
|
||||
}
|
||||
@@ -544,7 +660,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<CalendarDays size={16} />
|
||||
<CalendarDays size={16} className={"shrink-0"} />
|
||||
Registered on
|
||||
</>
|
||||
}
|
||||
@@ -560,7 +676,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<History size={16} />
|
||||
<History size={16} className={"shrink-0"} />
|
||||
Last seen
|
||||
</>
|
||||
}
|
||||
@@ -577,7 +693,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<NetBirdIcon size={16} />
|
||||
<NetBirdIcon size={16} className={"shrink-0"} />
|
||||
Agent Version
|
||||
</>
|
||||
}
|
||||
@@ -588,7 +704,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<NetBirdIcon size={16} />
|
||||
<NetBirdIcon size={16} className={"shrink-0"} />
|
||||
UI Version
|
||||
</>
|
||||
}
|
||||
@@ -686,82 +802,29 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
|
||||
);
|
||||
}
|
||||
|
||||
interface EditIPModalProps {
|
||||
onSuccess: (ip: string) => void;
|
||||
peer: Peer;
|
||||
}
|
||||
|
||||
function EditIPModal({ onSuccess, peer }: Readonly<EditIPModalProps>) {
|
||||
const [ip, setIP] = useState(peer.ip);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const validateIP = (ipAddress: string) => {
|
||||
const ipRegex =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
return ipRegex.test(ipAddress);
|
||||
};
|
||||
|
||||
const isDisabled = useMemo(() => {
|
||||
if (ip === peer.ip) return true;
|
||||
const trimmedIP = trim(ip);
|
||||
return trimmedIP.length === 0 || !validateIP(ip);
|
||||
}, [ip, peer.ip]);
|
||||
|
||||
React.useEffect(() => {
|
||||
switch (true) {
|
||||
case ip === peer.ip:
|
||||
setError("");
|
||||
break;
|
||||
case !validateIP(ip):
|
||||
setError("Please enter a valid IP, e.g., 100.64.0.15");
|
||||
break;
|
||||
default:
|
||||
setError("");
|
||||
break;
|
||||
}
|
||||
}, [ip, peer.ip]);
|
||||
|
||||
function EditableValue({
|
||||
value,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: {
|
||||
value: string;
|
||||
canEdit: boolean;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-md"}>
|
||||
<form>
|
||||
<ModalHeader
|
||||
title={"Edit Peer IP Address"}
|
||||
description={"Update the NetBird IP address for this peer."}
|
||||
color={"blue"}
|
||||
/>
|
||||
|
||||
<div className={"p-default flex flex-col gap-4"}>
|
||||
<div>
|
||||
<Input
|
||||
placeholder={"e.g., 100.64.0.15"}
|
||||
value={ip}
|
||||
onChange={(e) => setIP(e.target.value)}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout>Changes take effect when the peer reconnects.</Callout>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"} separator={false}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"} className={"w-full"}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => onSuccess(ip)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
<div className="flex items-center gap-2 justify-between w-full">
|
||||
<span>{value}</span>
|
||||
{canEdit && (
|
||||
<button
|
||||
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<PencilIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
70
src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx
Normal file
70
src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/app/(dashboard)/reverse-proxy/page.tsx
Normal file
15
src/app/(dashboard)/reverse-proxy/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Self-Hosted Proxies - Reverse Proxy - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
@@ -0,0 +1,68 @@
|
||||
"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 { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const SelfHostedProxiesTable = lazy(
|
||||
() =>
|
||||
import(
|
||||
"@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesTable"
|
||||
),
|
||||
);
|
||||
|
||||
export default function ReverseProxyClustersPage() {
|
||||
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/self-hosted-proxies"}
|
||||
label={"Self-Hosted Proxies"}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Self-Hosted Proxies</h1>
|
||||
<Paragraph>
|
||||
Setup self-hosted proxies on your own infrastructure for full control
|
||||
over traffic and geographic location.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink href={REVERSE_PROXY_CLUSTERS_DOCS_LINK} target={"_blank"}>
|
||||
Self-Hosted Proxies
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess
|
||||
page={"Self-Hosted Proxies"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<SelfHostedProxiesTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
8
src/app/(dashboard)/reverse-proxy/services/layout.tsx
Normal file
8
src/app/(dashboard)/reverse-proxy/services/layout.tsx
Normal 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;
|
||||
83
src/app/(dashboard)/reverse-proxy/services/page.tsx
Normal file
83
src/app/(dashboard)/reverse-proxy/services/page.tsx
Normal 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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { VerticalTabs } from "@components/VerticalTabs";
|
||||
import {
|
||||
AlertOctagonIcon,
|
||||
FingerprintIcon,
|
||||
FolderGit2Icon,
|
||||
LockIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
@@ -19,6 +20,7 @@ import { useAccount } from "@/modules/account/useAccount";
|
||||
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
|
||||
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
|
||||
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
|
||||
import IdentityProvidersTab from "@/modules/settings/IdentityProvidersTab";
|
||||
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
||||
import PermissionsTab from "@/modules/settings/PermissionsTab";
|
||||
import GroupsSettings from "@/modules/settings/GroupsSettings";
|
||||
@@ -53,6 +55,13 @@ export default function NetBirdSettings() {
|
||||
<ShieldIcon size={14} />
|
||||
Authentication
|
||||
</VerticalTabs.Trigger>
|
||||
{account?.settings?.embedded_idp_enabled &&
|
||||
permission?.identity_providers?.read && (
|
||||
<VerticalTabs.Trigger value="identity-providers">
|
||||
<FingerprintIcon size={14} />
|
||||
Identity Providers
|
||||
</VerticalTabs.Trigger>
|
||||
)}
|
||||
<VerticalTabs.Trigger value="groups">
|
||||
<FolderGit2Icon size={14} />
|
||||
Groups
|
||||
@@ -80,6 +89,8 @@ export default function NetBirdSettings() {
|
||||
>
|
||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||
{account && <AuthenticationTab account={account} />}
|
||||
{account?.settings?.embedded_idp_enabled &&
|
||||
permission.identity_providers.read && <IdentityProvidersTab />}
|
||||
{account && <PermissionsTab account={account} />}
|
||||
{account && <GroupsSettings account={account} />}
|
||||
{account && <NetworkSettingsTab account={account} />}
|
||||
|
||||
@@ -11,5 +11,5 @@ export default function Team() {
|
||||
router.push("/team/users");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { notify } from "@components/Notification";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
@@ -16,7 +17,15 @@ import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { generateColorFromString } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { Ban, GalleryHorizontalEnd, History, Mail, User2 } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
GalleryHorizontalEnd,
|
||||
History,
|
||||
KeyRoundIcon,
|
||||
Mail,
|
||||
MonitorSmartphoneIcon,
|
||||
User2,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
@@ -33,6 +42,7 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
|
||||
import UserBlockCell from "@/modules/users/table-cells/UserBlockCell";
|
||||
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
|
||||
import { UserPeersSection } from "@/modules/users/UserPeersSection";
|
||||
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
|
||||
|
||||
export default function UserPage() {
|
||||
@@ -80,6 +90,7 @@ type Props = {
|
||||
function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
const router = useRouter();
|
||||
const userRequest = useApiCall<User>("/users");
|
||||
const isServiceUser = !!user?.is_service_user;
|
||||
const { mutate } = useSWRConfig();
|
||||
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
|
||||
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
|
||||
@@ -91,7 +102,6 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
});
|
||||
|
||||
const [role, setRole] = useState(user.role || Role.User);
|
||||
|
||||
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
|
||||
role,
|
||||
selectedGroups,
|
||||
@@ -114,13 +124,24 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
`/${user.id}`,
|
||||
)
|
||||
.then(() => {
|
||||
mutate(`/users?service_user=${user.is_service_user}`);
|
||||
mutate(`/users?service_user=${isServiceUser}`);
|
||||
updateChangesRef([role, selectedGroups]);
|
||||
}),
|
||||
loadingMessage: "Saving changes...",
|
||||
});
|
||||
};
|
||||
|
||||
const isProfilePage = !!user?.is_current && !isServiceUser;
|
||||
const canViewTokens = permission?.pats?.read;
|
||||
const canViewPeers = permission?.peers?.read;
|
||||
|
||||
const showAccessTokens = (user?.is_current || isServiceUser) && canViewTokens;
|
||||
const showPeers = !isServiceUser && canViewPeers;
|
||||
const showTabs = isProfilePage && showPeers && showAccessTokens;
|
||||
const showSeparator = !showTabs;
|
||||
|
||||
const [tab, setTab] = useState(isServiceUser ? "access-tokens" : "peers");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6 mb-4"}>
|
||||
@@ -132,7 +153,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
icon={<TeamIcon size={13} />}
|
||||
/>
|
||||
|
||||
{user.is_service_user ? (
|
||||
{isServiceUser ? (
|
||||
<Breadcrumbs.Item
|
||||
href={"/team/service-users"}
|
||||
label={"Service Users"}
|
||||
@@ -158,7 +179,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
|
||||
}
|
||||
style={
|
||||
user.is_service_user
|
||||
isServiceUser
|
||||
? {
|
||||
color: "white",
|
||||
}
|
||||
@@ -171,13 +192,13 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
}
|
||||
}
|
||||
>
|
||||
{user.is_service_user ? (
|
||||
{isServiceUser ? (
|
||||
<IconSettings2 size={16} />
|
||||
) : (
|
||||
user?.name?.charAt(0) || user?.id?.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<h1 className={"flex items-center gap-3"}>
|
||||
<h1 className={"flex items-center gap-3"} title={user?.id}>
|
||||
{user.name || user.id}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -188,7 +209,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
variant={"default"}
|
||||
className={"w-full"}
|
||||
onClick={() => {
|
||||
user.is_service_user
|
||||
isServiceUser
|
||||
? router.push("/team/service-users")
|
||||
: router.push("/team/users");
|
||||
}}
|
||||
@@ -212,7 +233,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<UserInformationCard user={user} />
|
||||
<div className={"flex flex-col gap-8 w-1/2 "}>
|
||||
{!user.is_service_user && isOwnerOrAdmin && (
|
||||
{!isServiceUser && isOwnerOrAdmin && (
|
||||
<div>
|
||||
<Label>Auto-assigned groups</Label>
|
||||
<HelpText>
|
||||
@@ -238,7 +259,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
<UserRoleSelector
|
||||
value={role}
|
||||
onChange={setRole}
|
||||
hideOwner={user.is_service_user}
|
||||
hideOwner={isServiceUser}
|
||||
currentUser={user}
|
||||
disabled={isLoggedInUser || !permission.users.update}
|
||||
/>
|
||||
@@ -248,38 +269,65 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(user.is_current || user.is_service_user) && permission.pats.read && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className={"px-8 py-6"}>
|
||||
<div className={"max-w-6xl"}>
|
||||
<div className={"flex justify-between items-center"}>
|
||||
<div>
|
||||
<h2>Access Tokens</h2>
|
||||
<Paragraph>
|
||||
Access tokens give access to NetBird API.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"inline-flex gap-4 justify-end"}>
|
||||
{showSeparator && <Separator />}
|
||||
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"} hidden={!showTabs}>
|
||||
{showPeers && (
|
||||
<TabsTrigger value={"peers"}>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
Peers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{showAccessTokens && (
|
||||
<TabsTrigger value={"access-tokens"}>
|
||||
<KeyRoundIcon size={16} />
|
||||
Access Tokens
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
{showPeers && (
|
||||
<TabsContent value={"peers"} className={"pb-8"}>
|
||||
<UserPeersSection user={user} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{showAccessTokens && (
|
||||
<TabsContent value={"access-tokens"} className={"pb-8"}>
|
||||
<div className={"px-8"}>
|
||||
<div className={"max-w-6xl"}>
|
||||
<div className={"flex justify-between items-center"}>
|
||||
<div>
|
||||
<CreateAccessTokenModal user={user}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"access-token-open-modal"}
|
||||
disabled={!permission.pats.create}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Create Access Token
|
||||
</Button>
|
||||
</CreateAccessTokenModal>
|
||||
<h2>Access Tokens</h2>
|
||||
<Paragraph>
|
||||
Access tokens give access to NetBird API.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"inline-flex gap-4 justify-end"}>
|
||||
<div>
|
||||
<CreateAccessTokenModal user={user}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"access-token-open-modal"}
|
||||
disabled={!permission.pats.create}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Create Access Token
|
||||
</Button>
|
||||
</CreateAccessTokenModal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AccessTokensTable user={user} />
|
||||
</div>
|
||||
<AccessTokensTable user={user} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { notify } from "@components/Notification";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { IconCircleX } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
export default function RDPPage() {
|
||||
const { peerId } = useRDPQueryParams();
|
||||
@@ -84,7 +84,9 @@ function RDPSession({ peer }: Props) {
|
||||
try {
|
||||
setCredentials(rdpCredentials);
|
||||
setIsNetBirdConnecting(true);
|
||||
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
|
||||
await client.connectTemporary(peer.id, [
|
||||
`tcp/${rdpCredentials.port}`,
|
||||
]);
|
||||
setIsNetBirdConnecting(false);
|
||||
} catch (error) {
|
||||
sendErrorNotification(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { PageNotFound } from "@components/ui/PageNotFound";
|
||||
import useFetchApi, { ErrorResponse } from "@utils/api";
|
||||
import { isNativeSSHSupported } from "@utils/version";
|
||||
import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
@@ -13,9 +12,13 @@ import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
import {
|
||||
isNativeSSHSupported,
|
||||
isNetbirdSSHProtocolSupported,
|
||||
} from "@utils/version";
|
||||
|
||||
export default function SSHPage() {
|
||||
const { peerId, username, port } = useSSHQueryParams();
|
||||
const { peerId, username, port, ipVersion } = useSSHQueryParams();
|
||||
|
||||
const {
|
||||
data: peer,
|
||||
@@ -45,6 +48,7 @@ export default function SSHPage() {
|
||||
peer={peer}
|
||||
username={username}
|
||||
port={port}
|
||||
ipVersion={ipVersion}
|
||||
/>
|
||||
) : (
|
||||
<LoadingMessage message={"Starting ssh session..."} />
|
||||
@@ -57,9 +61,10 @@ type Props = {
|
||||
username: string;
|
||||
port: string;
|
||||
peer: Peer;
|
||||
ipVersion: string | null;
|
||||
};
|
||||
|
||||
function SSHTerminal({ username, port, peer }: Props) {
|
||||
function SSHTerminal({ username, port, peer, ipVersion }: Props) {
|
||||
const client = useNetBirdClient();
|
||||
const connected = useRef(false);
|
||||
const sshConnectedOnce = useRef(false);
|
||||
@@ -78,9 +83,12 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
|
||||
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
|
||||
|
||||
// Use the FQDN when an IP version is specified so the dialer resolves to the correct address family.
|
||||
const sshHost = ipVersion ? peer.dns_label || peer.ip : peer.ip;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
|
||||
}, [username, peer, client]);
|
||||
document.title = `${username}@${sshHost} - ${peer.hostname}`;
|
||||
}, [username, peer, client, sshHost]);
|
||||
|
||||
const handleReconnect = async () => {
|
||||
if (!peer?.id) return;
|
||||
@@ -88,12 +96,16 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
connected.current = false;
|
||||
try {
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const rules = [`tcp/${aclPort}`];
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
const rules = [`${protocol}/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
await ssh({
|
||||
hostname: peer.ip,
|
||||
hostname: sshHost,
|
||||
port: Number(port),
|
||||
username,
|
||||
ipVersion: ipVersion || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Reconnection failed:", error);
|
||||
@@ -108,20 +120,25 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
if (!peer.id) return;
|
||||
if (connected.current) return;
|
||||
connected.current = true;
|
||||
|
||||
try {
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const rules = [`tcp/${aclPort}`];
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
const rules = [`${protocol}/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
const res = await ssh({
|
||||
hostname: peer.ip,
|
||||
hostname: sshHost,
|
||||
port: Number(port),
|
||||
username,
|
||||
ipVersion: ipVersion || undefined,
|
||||
});
|
||||
if (res === SSHStatus.CONNECTED) {
|
||||
sshConnectedOnce.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Connection failed:", error);
|
||||
console.error("Connection error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
8
src/app/invite/layout.tsx
Normal file
8
src/app/invite/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Accept Invite - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
321
src/app/invite/page.tsx
Normal file
321
src/app/invite/page.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { Input } from "@components/Input";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { acceptInvite, fetchInviteInfo } from "@utils/unauthenticatedApi";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
KeyRound,
|
||||
Mail,
|
||||
User2,
|
||||
} from "lucide-react";
|
||||
import dayjs from "dayjs";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import { UserInviteInfo } from "@/interfaces/User";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
return (
|
||||
<Suspense fallback={<FullScreenLoading />}>
|
||||
<InviteAcceptContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteAcceptContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const token = searchParams?.get("token");
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [inviteInfo, setInviteInfo] = useState<UserInviteInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRateLimited, setIsRateLimited] = useState(false);
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError("No invite token provided");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchInviteInfo(token)
|
||||
.then((info) => {
|
||||
setInviteInfo(info);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code === 429) {
|
||||
setError("Too many attempts. Please wait a moment and try again.");
|
||||
setIsRateLimited(true);
|
||||
} else {
|
||||
setError(err.message || "Invalid or expired invite link");
|
||||
setIsRateLimited(false);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
const passwordsMatch = password === confirmPassword;
|
||||
const hasMinLength = password.length >= 8;
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
|
||||
const passwordValid = hasMinLength && hasUppercase && hasLowercase && hasNumber && hasSpecialChar;
|
||||
const canSubmit = passwordValid && passwordsMatch && !submitting;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit || !token) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await acceptInvite(token, password);
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to accept invite");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isExpired = useMemo(() => {
|
||||
if (!inviteInfo) return false;
|
||||
return new Date(inviteInfo.expires_at) < new Date();
|
||||
}, [inviteInfo]);
|
||||
|
||||
if (loading) {
|
||||
return <FullScreenLoading />;
|
||||
}
|
||||
|
||||
if (error && !inviteInfo) {
|
||||
if (isRateLimited) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
|
||||
<Clock className="w-8 h-8 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">
|
||||
Too Many Requests
|
||||
</h1>
|
||||
<Paragraph className="text-nb-gray-400 text-base">
|
||||
You've made too many requests. Please wait a moment and try
|
||||
again.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-6"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">
|
||||
Invalid Invite
|
||||
</h1>
|
||||
<Paragraph className="text-nb-gray-400 text-base">
|
||||
This invite link is invalid or has expired. Please contact your
|
||||
administrator to receive a new invitation.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-6"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="w-16 h-16 bg-green-500/10 rounded-full flex items-center justify-center">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">
|
||||
Account Created!
|
||||
</h1>
|
||||
<Paragraph className="text-nb-gray-400">
|
||||
Your account has been created successfully. You can now log in with
|
||||
your email and password.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-6"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isExpired || !inviteInfo?.valid) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="w-8 h-8 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">
|
||||
Invite Expired
|
||||
</h1>
|
||||
<Paragraph className="text-nb-gray-400">
|
||||
This invite link has expired. Please contact your administrator to
|
||||
receive a new invitation.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-6"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="mb-8 flex justify-center">
|
||||
<NetBirdIcon size={48} />
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">
|
||||
Welcome to NetBird
|
||||
</h1>
|
||||
<p className="dark:text-nb-gray-400 text-nb-gray-500 text-base">
|
||||
You've been invited by <span className="dark:text-white text-nb-gray-900 font-medium">{inviteInfo.invited_by}</span> to join the network. Set your password to complete your account setup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-nb-gray-930 border border-nb-gray-900 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-nb-gray-900 rounded-full flex items-center justify-center">
|
||||
<User2 className="w-5 h-5 text-nb-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-medium">{inviteInfo.name}</div>
|
||||
<div className="text-nb-gray-400 text-sm flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" />
|
||||
{inviteInfo.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
customPrefix={
|
||||
<KeyRound size={16} className="text-nb-gray-400" />
|
||||
}
|
||||
/>
|
||||
{password && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<PasswordRule met={hasMinLength} text="At least 8 characters" />
|
||||
<PasswordRule met={hasUppercase} text="One uppercase letter" />
|
||||
<PasswordRule met={hasLowercase} text="One lowercase letter" />
|
||||
<PasswordRule met={hasNumber} text="One number" />
|
||||
<PasswordRule met={hasSpecialChar} text="One special character (!@#$%^&*)" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm Password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
customPrefix={
|
||||
<KeyRound size={16} className="text-nb-gray-400" />
|
||||
}
|
||||
/>
|
||||
{confirmPassword && !passwordsMatch && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
Passwords do not match
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-3">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{submitting ? "Creating Account..." : "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-nb-gray-500">
|
||||
Invite expires on {dayjs(inviteInfo.expires_at).format("D MMMM, YYYY [at] h:mm A")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordRule({ met, text }: { met: boolean; text: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{met ? (
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-3 h-3 text-nb-gray-500" />
|
||||
)}
|
||||
<span className={met ? "text-green-500" : "text-nb-gray-500"}>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,6 @@ export default function NotFound() {
|
||||
|
||||
const Redirect = ({ url, queryParams }: Props) => {
|
||||
const params = queryParams && `?${queryParams}`;
|
||||
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`);
|
||||
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`, true);
|
||||
return <FullScreenLoading />;
|
||||
};
|
||||
|
||||
@@ -37,6 +37,6 @@ export default function Home() {
|
||||
|
||||
const Redirect = ({ url, queryParams }: Props) => {
|
||||
const params = queryParams && `?${queryParams}`;
|
||||
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`);
|
||||
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`, true);
|
||||
return <FullScreenLoading />;
|
||||
};
|
||||
|
||||
8
src/app/setup/layout.tsx
Normal file
8
src/app/setup/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Instance Setup - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
22
src/app/setup/page.tsx
Normal file
22
src/app/setup/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import InstanceSetupWizard from "@/modules/instance-setup/InstanceSetupWizard";
|
||||
import { useInstanceSetup } from "@/contexts/InstanceSetupProvider";
|
||||
import { useRouter } from "next/navigation";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function SetupPage() {
|
||||
const { setupRequired, loading } = useInstanceSetup();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !setupRequired) router.replace("/peers");
|
||||
}, [loading, setupRequired]);
|
||||
|
||||
return loading || !setupRequired ? (
|
||||
<FullScreenLoading />
|
||||
) : (
|
||||
<InstanceSetupWizard />
|
||||
);
|
||||
}
|
||||
28
src/assets/icons/AuthentikIcon.tsx
Normal file
28
src/assets/icons/AuthentikIcon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function AuthentikIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="-0.03 59.9 512.03 392.1"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M279.9 141h17.9v51.2h-17.9zm46.6-2.2h17.9v40h-17.9zM65.3 197.3c-24 0-46 13.2-57.4 34.3h30.4c13.5-11.6 33-15 47.1 0h32.2c-12.6-17.1-31.4-34.3-52.3-34.3"
|
||||
fill="#fd4b2d"
|
||||
/>
|
||||
<path
|
||||
d="M108.7 262.4C66.8 350-6.6 275.3 38.3 231.5H7.9C-15.9 273 17 329 65.3 327.8c37.4 0 68.2-55.5 68.2-65.3 0-4.3-6-17.6-16-31H85.4c10.7 9.7 20 23.7 23.3 30.9m1.1-2.6"
|
||||
fill="#fd4b2d"
|
||||
/>
|
||||
<path
|
||||
d="M512 140.3v231.3c0 44.3-36.1 80.4-80.4 80.4h-34.1v-78.8h-163V452h-34.1c-44.4 0-80.4-36.1-80.4-80.4v-72.8h258.4v-139H253.6V238H119.9v-97.6c0-3.1.2-6.2.5-9.2.4-3.7 1.1-7.3 2-10.8.3-1.1.6-2.3 1-3.4.1-.3.2-.6.3-.8.2-.6.4-1.1.5-1.7.2-.5.4-1.1.6-1.7s.5-1.2.7-1.8.5-1.2.8-1.8c2-4.7 4.4-9.3 7.3-13.6l.1-.1c.7-1.1 1.5-2.1 2.3-3.2.7-.9 1.3-1.7 2-2.6.8-.9 1.6-1.9 2.4-2.8s1.6-1.8 2.4-2.6l.1-.1c.4-.5.9-.9 1.4-1.4 3-2.9 6.2-5.6 9.6-8 .9-.7 1.9-1.3 2.8-1.9 1.1-.7 2.2-1.4 3.3-2 2.1-1.2 4.2-2.4 6.5-3.4.7-.3 1.4-.7 2.1-1 3.1-1.3 6.2-2.5 9.4-3.4 1.2-.4 2.5-.7 3.7-1 .6-.2 1.2-.3 1.8-.4 3.6-.8 7.2-1.3 10.9-1.6l1.6-.1h.8c1.2-.1 2.4-.1 3.7-.1h231.3c1.2 0 2.5 0 3.7.1h.8l1.6.1c3.7.3 7.3.8 10.9 1.6.6.1 1.2.3 1.8.4 1.3.3 2.5.6 3.7 1 3.2.9 6.3 2.1 9.4 3.4.7.3 1.4.6 2.1 1 2.2 1 4.4 2.2 6.5 3.4 1.1.7 2.2 1.3 3.3 2 1 .6 1.9 1.3 2.8 1.9 3.9 2.8 7.6 6 11 9.4.8.8 1.7 1.7 2.4 2.6.8.9 1.6 1.9 2.4 2.8.7.8 1.3 1.7 2 2.6.8 1.1 1.5 2.1 2.3 3.2l.1.1c2.9 4.3 5.3 8.8 7.3 13.6.2.6.5 1.2.8 1.8.2.6.5 1.2.7 1.8.2.5.4 1.1.6 1.7s.4 1.1.5 1.7c.1.3.2.6.3.8.3 1.1.7 2.3 1 3.4.9 3.6 1.6 7.2 2 10.8 0 3.1.2 6.1.2 9.2"
|
||||
fill="#fd4b2d"
|
||||
/>
|
||||
<path
|
||||
d="M498.3 95.5H133.5c14.9-22.2 40-35.6 66.7-35.6h231.3c26.9 0 51.9 13.4 66.8 35.6m13.2 35.6H120.4c1.4-12.8 6-25 13.1-35.6h364.8c7.2 10.6 11.7 22.9 13.2 35.6m.5 9.2v26.4H378.3v-6.9H253.6v6.9H119.9v-26.4c0-3.1.2-6.2.5-9.2h391.1c.3 3.1.5 6.1.5 9.2M119.9 166.7h133.7v35.6H119.9zm258.4 0H512v35.6H378.3zm-258.4 35.6h133.7v35.6H119.9zm258.4 0H512v35.6H378.3z"
|
||||
fill="#fd4b2d"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
src/assets/icons/DNSZoneIcon.tsx
Normal file
19
src/assets/icons/DNSZoneIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function DNSZoneIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5 5a2 2 0 0 0-2 2v3a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V7a2 2 0 0 0-2-2H5Zm9 2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17ZM3 17v-3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm11-2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
31
src/assets/icons/IdentityProviderIcons.tsx
Normal file
31
src/assets/icons/IdentityProviderIcons.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { SSOIdentityProviderType } from "@/interfaces/IdentityProvider";
|
||||
import React from "react";
|
||||
import GoogleIcon from "@/assets/icons/GoogleIcon";
|
||||
import MicrosoftIcon from "@/assets/icons/MicrosoftIcon";
|
||||
import EntraIcon from "@/assets/icons/EntraIcon";
|
||||
import OktaIcon from "@/assets/icons/OktaIcon";
|
||||
import PocketIdIcon from "@/assets/icons/PocketIdIcon";
|
||||
import ZitadelIcon from "@/assets/icons/ZitadelIcon";
|
||||
import AuthentikIcon from "@/assets/icons/AuthentikIcon";
|
||||
import KeycloakIcon from "@/assets/icons/KeycloakIcon";
|
||||
import { KeyRound } from "lucide-react";
|
||||
|
||||
export const idpIcon = (
|
||||
type: SSOIdentityProviderType,
|
||||
size: number = 16,
|
||||
): React.ReactNode => {
|
||||
const icons: Record<SSOIdentityProviderType, React.ReactNode> = {
|
||||
google: <GoogleIcon size={size} />,
|
||||
microsoft: <MicrosoftIcon size={size} />,
|
||||
entra: <EntraIcon size={size} />,
|
||||
okta: <OktaIcon size={size} className="text-nb-gray-300" />,
|
||||
pocketid: <PocketIdIcon size={size} />,
|
||||
zitadel: <ZitadelIcon size={size} />,
|
||||
authentik: <AuthentikIcon size={size} />,
|
||||
keycloak: <KeycloakIcon size={size} />,
|
||||
adfs: <MicrosoftIcon size={size} />,
|
||||
oidc: <KeyRound size={size} className="text-nb-gray-400" />,
|
||||
};
|
||||
|
||||
return icons[type];
|
||||
};
|
||||
19
src/assets/icons/JumpcloudIcon.tsx
Normal file
19
src/assets/icons/JumpcloudIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function JumpcloudIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
width="167"
|
||||
height="82"
|
||||
viewBox="0 0 167 82"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M166.911 58.3592C166.911 64.3815 164.519 70.1571 160.26 74.4155C156.002 78.6739 150.226 81.0662 144.204 81.0662H137.961C137.31 73.4972 129.5 67.0612 118.46 64.0722C121.244 61.3253 123.148 57.8124 123.931 53.9803C124.713 50.1482 124.338 46.17 122.854 42.5515C121.369 38.933 118.842 35.8378 115.594 33.6594C112.345 31.481 108.522 30.3178 104.611 30.3178C100.7 30.3178 96.8772 31.481 93.6289 33.6594C90.3805 35.8378 87.8534 38.933 86.3689 42.5515C84.8843 46.17 84.5094 50.1482 85.2918 53.9803C86.0743 57.8124 87.9786 61.3253 90.7628 64.0722C85.5111 65.3278 80.6301 67.8055 76.5167 71.3037C73.9207 69.8152 71.1411 68.6726 68.2487 67.9049C70.6422 65.5587 72.2829 62.5529 72.9614 59.2707C73.6399 55.9884 73.3255 52.5784 72.0584 49.4755C70.7913 46.3726 68.6288 43.7174 65.8467 41.8484C63.0646 39.9793 59.7888 38.9812 56.4372 38.9812C53.0855 38.9812 49.8098 39.9793 47.0277 41.8484C44.2455 43.7174 42.0831 46.3726 40.816 49.4755C39.5488 52.5784 39.2345 55.9884 39.913 59.2707C40.5915 62.5529 42.2321 65.5587 44.6257 67.9049C35.9237 70.3154 29.5841 75.1364 28.2342 80.9698H21.991C16.0936 80.7777 10.502 78.2999 6.39821 74.0603C2.2944 69.8206 0 64.1513 0 58.2508C0 52.3503 2.2944 46.681 6.39821 42.4413C10.502 38.2016 16.0936 35.7238 21.991 35.5317C24.8814 35.5419 27.7438 36.0981 30.4278 37.1709C32.2478 33.2162 35.1686 29.8695 38.8407 27.5312C42.5128 25.1928 46.7807 23.9618 51.1341 23.9854C51.6885 23.9854 52.2429 23.9854 52.7732 23.9854C53.9093 18.1059 56.8018 12.7093 61.0689 8.50798C65.336 4.30669 70.7769 1.49837 76.6733 0.453829C82.5698 -0.590709 88.6443 0.177651 94.095 2.65746C99.546 5.13728 104.116 9.21191 107.203 14.3434C110.733 13.2708 114.463 13.023 118.104 13.6193C121.746 14.2155 125.202 15.6397 128.206 17.7822C131.21 19.9247 133.682 22.7283 135.432 25.977C137.182 29.2257 138.162 32.8326 138.298 36.52C141.665 35.6031 145.198 35.4762 148.622 36.1492C152.046 36.8222 155.269 38.277 158.038 40.4001C160.808 42.5233 163.049 45.2574 164.588 48.3892C166.127 51.5211 166.922 54.9661 166.911 58.4557V58.3592Z"
|
||||
fill="#4CC2BF"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
88
src/assets/icons/KeycloakIcon.tsx
Normal file
88
src/assets/icons/KeycloakIcon.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function KeycloakIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<g transform="translate(.714 .07)">
|
||||
<path
|
||||
d="M432.9 149.2c-1.4 0-2.7-.7-3.4-2L370.1 44.1c-.7-1.2-2-2-3.5-2H124.2c-1.4 0-2.7.7-3.4 2L58.9 150.9l23.9 34.9c-.7 1.2-6.2 24-5.5 25.2L58.9 360.9l61.9 106.9c.7 1.2 2 2 3.4 2h242.4c1.4 0 2.7-.7 3.5-2l59.4-103.2c.7-1.2 2-2 3.4-2h73.8c2.4 0 4.4-2 4.4-4.4V153.6c0-2.4-2-4.4-4.4-4.4z"
|
||||
fill="#4d4d4d"
|
||||
/>
|
||||
<path d="M72.7 245.3 6.4 269.4l-6.6-11.3c-.7-1.2-.7-2.7 0-3.9l30-52z" fill="#e1e1e1" />
|
||||
<path d="M511.3 258.3V309l-43.7-44.5z" fill="#c8c8c8" />
|
||||
<path
|
||||
d="m467.5 264.5 43.7 44.5v49.6c0 2.4-2 4.4-4.4 4.4H456z"
|
||||
fill="#c2c2c2"
|
||||
/>
|
||||
<path d="M467.5 264.5 456 362.9h-61.2l-18.5-44.7z" fill="#c7c7c7" />
|
||||
<path d="M511.3 211.2v47l-43.7 6.2z" fill="#cecece" />
|
||||
<path
|
||||
d="M511.3 153.6v57.6l-43.7 53.2-33.1-115.3h72.2c2.4-.1 4.5 1.8 4.6 4.3z"
|
||||
fill="#d3d3d3"
|
||||
/>
|
||||
<path d="M394.8 362.9h-32.3l-8.4-12 22.1-32.7z" fill="#c6c6c6" />
|
||||
<path d="m467.5 264.5-121.1-51.2 63.7-64.1h24.4z" fill="#d5d5d5" />
|
||||
<path d="m346.5 213.3 29.8 105 91.2-53.8z" fill="#d0d0d0" />
|
||||
<path d="m353.8 362.9.4-12 8.4 12z" fill="#bfbfbf" />
|
||||
<path d="m410.1 149.2-63.7 64.1-11.4-57.4 24.6-6.8h50.5z" fill="#d9d9d9" />
|
||||
<path d="m346.5 213.3-147 33.9 154.7 103.7z" fill="#d4d4d4" />
|
||||
<path d="m346.5 213.3 7.7 137.6 22.1-32.7z" fill="#d0d0d0" />
|
||||
<path d="m335 155.9-135.5 91.2 147-33.9z" fill="#d9d9d9" />
|
||||
<path d="m199.5 247.2-63.7 115.7H99.6L72.7 245.3z" fill="#d8d8d8" />
|
||||
<path
|
||||
d="m134.3 149.2-61.5 96.1L57.3 155l2.2-3.8c.7-1.2 2-1.9 3.4-1.9z"
|
||||
fill="#e2e2e2"
|
||||
/>
|
||||
<path
|
||||
d="M99.6 362.9H62.7c-1.4 0-2.8-.8-3.5-2L6.4 269.4l66.4-24.1z"
|
||||
fill="#d8d8d8"
|
||||
/>
|
||||
<path d="M29.9 202.1 57.1 155l15.7 90.3z" fill="#e4e4e4" />
|
||||
<path d="m335 155.9-40.8-6.8H159.4l40.1 98z" fill="#dedede" />
|
||||
<path d="m199.5 247.2-40.1-98h-25.1l-61.5 96.1z" fill="#dedede" />
|
||||
<path d="M324.7 362.9h29.1l.4-12z" fill="#c5c5c5" />
|
||||
<path d="M266.7 362.9h58l29.5-12-154.7-103.7 27.9 115.7z" fill="#d0d0d0" />
|
||||
<path d="m227.4 362.9-27.9-115.7-63.7 115.7z" fill="#d1d1d1" />
|
||||
<path d="m335.4 149.2-.4 6.8 24.6-6.8z" fill="#ddd" />
|
||||
<path d="m335 155.9-3.8-6.8h-37z" fill="#e3e3e3" />
|
||||
<path d="m335 155.9.4-6.8h-4.2z" fill="#e2e2e2" />
|
||||
<path
|
||||
d="m223.9 151-59.7 103.4c-.3.5-.4 1.1-.4 1.7h-41.7l82-142q.75.45 1.2 1.2l18.6 32.3c.5 1.1.5 2.4 0 3.4"
|
||||
fill="#00b8e3"
|
||||
/>
|
||||
<path
|
||||
d="M223.8 364.9 205.3 397q-.45.75-1.2 1.2l-82-142.2h41.7c0 .6.1 1.1.4 1.6l59.6 103.2c.8 1.2.9 2.9 0 4.1"
|
||||
fill="#33c6e9"
|
||||
/>
|
||||
<path
|
||||
d="m204 114.2-82 141.9-20.6 35.6-19.6-34c-.3-.5-.4-1-.4-1.6s.1-1.2.4-1.7l19.9-34.4 60.4-104.5c.6-1.1 1.8-1.8 3-1.8h37.2c.6 0 1.2.2 1.7.5"
|
||||
fill="#008aaa"
|
||||
/>
|
||||
<path
|
||||
d="M204 398.2c-.5.3-1.1.5-1.8.5h-37.1c-1.3 0-2.4-.7-3-1.8l-55.2-95.6-5.5-9.5 20.6-35.6z"
|
||||
fill="#00b8e3"
|
||||
/>
|
||||
<path
|
||||
d="m368.9 256.1-82 142q-.75-.45-1.2-1.2L267 364.7c-.5-1-.5-2.3 0-3.3L326.7 258c.3-.5.5-1.2.5-1.8z"
|
||||
fill="#008aaa"
|
||||
/>
|
||||
<path
|
||||
d="M409.4 256.1c0 .6-.2 1.3-.5 1.8l-80.3 139.3c-.6 1-1.8 1.7-3 1.6h-37c-.6 0-1.2-.2-1.8-.5L368.9 256l20.6-35.6 19.5 33.8c.3.7.4 1.3.4 1.9"
|
||||
fill="#00b8e3"
|
||||
/>
|
||||
<path
|
||||
d="M368.9 256.1h-41.7c0-.6-.2-1.2-.5-1.8L267 151.2c-.6-1.1-.6-2.5 0-3.6l18.6-32.2q.45-.75 1.2-1.2z"
|
||||
fill="#00b8e3"
|
||||
/>
|
||||
<path
|
||||
d="m389.4 220.5-20.6 35.6-82-142c.6-.3 1.2-.5 1.8-.5h37.1c1.2 0 2.3.6 3 1.6z"
|
||||
fill="#33c6e9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
src/assets/icons/MicrosoftIcon.tsx
Normal file
16
src/assets/icons/MicrosoftIcon.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function MicrosoftIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 221 221"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path fill="#F1511B" d="M104.868 104.868H0V0h104.868z" />
|
||||
<path fill="#80CC28" d="M220.654 104.868H115.788V0h104.866z" />
|
||||
<path fill="#00ADEF" d="M104.865 220.695H0V115.828h104.865z" />
|
||||
<path fill="#FBBC09" d="M220.654 220.695H115.788V115.828h104.866z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
27
src/assets/icons/OIDCIcon.tsx
Normal file
27
src/assets/icons/OIDCIcon.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function OIDCIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
width="173"
|
||||
height="174"
|
||||
viewBox="0 0 173 174"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M76.3945 173.48L103.325 154.065L102.072 0L76.3945 20.041V173.48Z"
|
||||
fill="#FF8E00"
|
||||
/>
|
||||
<path
|
||||
d="M76.7077 173.48C-24.0221 157.466 -26.8926 69.7689 76.0814 50.7288L76.3945 68.8909C3.35034 81.0694 12.6045 146.598 76.3945 156.257L76.7077 173.48Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M103.011 68.2646C115.468 68.3493 126.32 74.0515 137.144 79.8508L121.174 91.7502H172.216L172.529 56.9916L156.558 68.8909C140.397 60.7278 125.542 50.9315 103.011 50.7288V68.2646Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
src/assets/icons/PeerOSIcon.tsx
Normal file
26
src/assets/icons/PeerOSIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
src/assets/icons/PeerOrResourceIcon.tsx
Normal file
19
src/assets/icons/PeerOrResourceIcon.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
17
src/assets/icons/PocketIdIcon.tsx
Normal file
17
src/assets/icons/PocketIdIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function PocketIdIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<circle cx="256" cy="256" r="256" fill="#fff" />
|
||||
<path
|
||||
d="M268.6 102.4c64.4 0 116.8 52.4 116.8 116.7 0 25.3-8 49.4-23 69.6-14.8 19.9-35 34.3-58.4 41.7l-6.5 2-15.5-76.2 4.3-2c14-6.7 23-21.1 23-36.6 0-22.4-18.2-40.6-40.6-40.6S228 195.2 228 217.6c0 15.5 9 29.8 23 36.6l4.2 2-25 153.4h-69.5V102.4z"
|
||||
fill="#191919"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
src/assets/icons/ResourceIcon.tsx
Normal file
20
src/assets/icons/ResourceIcon.tsx
Normal 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} />;
|
||||
}
|
||||
};
|
||||
19
src/assets/icons/ReverseProxyIcon.tsx
Normal file
19
src/assets/icons/ReverseProxyIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/assets/icons/SlackIcon.tsx
Normal file
30
src/assets/icons/SlackIcon.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function SlackIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
width="127"
|
||||
height="127"
|
||||
viewBox="0 0 127 127"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
32
src/assets/icons/ZitadelIcon.tsx
Normal file
32
src/assets/icons/ZitadelIcon.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function ZitadelIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 80 79"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="zitadel-grad"
|
||||
x1="3.86"
|
||||
x2="76.88"
|
||||
y1="47.89"
|
||||
y2="47.89"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF8F00" />
|
||||
<stop offset="1" stopColor="#FE00FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#zitadel-grad)"
|
||||
fillRule="evenodd"
|
||||
d="M17.12 39.17l1.42 5.32-6.68 6.68 9.12 2.44 1.43 5.32-19.77-5.3L17.12 39.17zM58.82 22.41l-5.32-1.43-2.44-9.12-6.68 6.68-5.32-1.43 14.47-14.47 5.3 19.77zM52.65 67.11l3.89-3.89 9.12 2.44-2.44-9.12 3.9-3.9 5.29 19.77-19.76-5.3zM36.43 69.54l-1.18-12.07 8.23 2.21-7.05 9.86zM23 23.84l5.02 11.04 6.02-6.02L23 23.84zM69.32 36.2l-12.07-1.18 2.2 8.23 9.87-7.05z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
BIN
src/assets/integrations/crowdsec.png
Normal file
BIN
src/assets/integrations/crowdsec.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
src/assets/onboarding/acl.png
Normal file
BIN
src/assets/onboarding/acl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
src/assets/onboarding/activity.png
Normal file
BIN
src/assets/onboarding/activity.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 261 KiB |
BIN
src/assets/onboarding/posture.png
Normal file
BIN
src/assets/onboarding/posture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
@@ -6,10 +6,8 @@ import {
|
||||
OidcProvider,
|
||||
} from "@axa-fr/react-oidc";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useLocalStorage } from "@hooks/useLocalStorage";
|
||||
import { useRedirect } from "@hooks/useRedirect";
|
||||
import loadConfig, { buildExtras } from "@utils/config";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { OIDCError } from "@/auth/OIDCError";
|
||||
import { SecureProvider } from "@/auth/SecureProvider";
|
||||
@@ -44,39 +42,11 @@ export default function OIDCProvider({ children }: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const path = usePathname();
|
||||
const params = useSearchParams()?.toString();
|
||||
const [, setQueryParams] = useLocalStorage("netbird-query-params", params);
|
||||
|
||||
useEffect(() => {
|
||||
const validParams = [
|
||||
"tab",
|
||||
"search",
|
||||
"id",
|
||||
"invite",
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_content",
|
||||
"utm_campaign",
|
||||
"hs_id",
|
||||
"page",
|
||||
"page_size",
|
||||
"user",
|
||||
"port",
|
||||
];
|
||||
|
||||
try {
|
||||
const urlParams = new URLSearchParams(params);
|
||||
if (validParams.some((param) => urlParams.has(param))) {
|
||||
setQueryParams(params);
|
||||
}
|
||||
} catch (e) {}
|
||||
}, []);
|
||||
|
||||
const withCustomHistory = () => {
|
||||
return {
|
||||
replaceState: (url: any) => {
|
||||
router.replace(url);
|
||||
window.dispatchEvent(new Event("popstate"));
|
||||
window?.location?.replace(url);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -105,16 +75,19 @@ export default function OIDCProvider({ children }: Props) {
|
||||
|
||||
// We bypass authentication for pages that do not require auth.
|
||||
// E.g., when we just want to show installation steps for public.
|
||||
if (path === "/install") return children;
|
||||
// Or the instance setup wizard for first-time setup.
|
||||
// Or the invite acceptance page for new users.
|
||||
if (path === "/install" || path === "/setup" || path?.startsWith("/invite"))
|
||||
return children;
|
||||
|
||||
return mounted && providerConfig ? (
|
||||
<OidcProvider
|
||||
configuration={providerConfig}
|
||||
//withCustomHistory={withCustomHistory}
|
||||
withCustomHistory={withCustomHistory}
|
||||
authenticatingComponent={FullScreenLoading}
|
||||
authenticatingErrorComponent={OIDCError}
|
||||
loadingComponent={FullScreenLoading}
|
||||
callbackSuccessComponent={CallBackSuccess}
|
||||
callbackSuccessComponent={FullScreenLoading}
|
||||
onEvent={onEvent}
|
||||
onSessionLost={() => void 0}
|
||||
//sessionLostComponent={SessionLost}
|
||||
@@ -125,11 +98,3 @@ export default function OIDCProvider({ children }: Props) {
|
||||
<FullScreenLoading />
|
||||
);
|
||||
}
|
||||
|
||||
const CallBackSuccess = () => {
|
||||
const params = useSearchParams();
|
||||
const errorParam = params.get("error");
|
||||
const currentPath = usePathname();
|
||||
useRedirect(currentPath, true, !errorParam);
|
||||
return <FullScreenLoading />;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
],
|
||||
@@ -32,6 +33,10 @@ const variants = cva("", {
|
||||
green: ["bg-green-950 border-green-500 border text-green-400"],
|
||||
netbird: ["bg-netbird-950 border-netbird-500 border text-netbird-500"],
|
||||
},
|
||||
size: {
|
||||
default: "text-[0.75rem] py-1.5 px-3",
|
||||
xs: "text-[0.6rem] py-[0.3rem] px-2",
|
||||
},
|
||||
hover: {
|
||||
none: [],
|
||||
blue: ["hover:bg-sky-200"],
|
||||
@@ -41,8 +46,9 @@ 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-900"],
|
||||
"gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"],
|
||||
green: ["hover:bg-green-950/50"],
|
||||
netbird: ["hover:bg-netbird-950/50"],
|
||||
},
|
||||
@@ -53,6 +59,7 @@ export default function Badge({
|
||||
children,
|
||||
className,
|
||||
variant = "blue",
|
||||
size = "default",
|
||||
useHover = false,
|
||||
disabled = false,
|
||||
...props
|
||||
@@ -60,8 +67,8 @@ export default function Badge({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 cursor-inherit whitespace-nowrap rounded-md text-[12px] py-1.5 px-3 font-normal flex gap-1.5 items-center justify-center transition-all",
|
||||
variants({ variant, hover: useHover ? variant : "none" }),
|
||||
"relative z-10 cursor-inherit whitespace-nowrap rounded-md font-normal flex gap-1.5 items-center justify-center transition-all",
|
||||
variants({ variant, hover: useHover ? variant : "none", size }),
|
||||
disabled && "cursor-not-allowed opacity-50 select-none",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ export const buttonVariants = cva(
|
||||
secondary: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
|
||||
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910",
|
||||
],
|
||||
secondaryLighter: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
@@ -54,7 +54,7 @@ export const buttonVariants = cva(
|
||||
dotted: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
|
||||
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
tertiary: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
@@ -73,9 +73,13 @@ export const buttonVariants = cva(
|
||||
"enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500",
|
||||
"",
|
||||
],
|
||||
"danger-text": [
|
||||
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 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",
|
||||
"data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50",
|
||||
],
|
||||
danger: [
|
||||
"", // TODO - add danger button styles for light mode
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -50,11 +50,11 @@ function CardListItem({
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex justify-between px-4 border-b border-nb-gray-900 py-4 last:border-b-0 items-center h-full",
|
||||
"flex justify-between px-4 border-b border-nb-gray-900 py-3.5 last:border-b-0 items-center h-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={"flex gap-2.5 items-center text-sm"}>{label}</div>
|
||||
<div className={"flex gap-2.5 items-center text-[0.84rem]"}>{label}</div>
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<CardTextItem
|
||||
label={label}
|
||||
@@ -100,7 +100,7 @@ const CardTextItem = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
|
||||
"text-right text-nb-gray-400 text-[0.84rem] flex items-center gap-2",
|
||||
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
|
||||
)}
|
||||
onClick={() =>
|
||||
|
||||
129
src/components/CardTable.tsx
Normal file
129
src/components/CardTable.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import useCopyToClipboard from "@hooks/useCopyToClipboard";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Copy } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
type CardTableProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTable({ children, className }: CardTableProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-nb-gray-940 rounded-md border border-nb-gray-900 w-full overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<table className={"w-full border-collapse text-sm"}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTableHeader({ children, className }: CardTableProps) {
|
||||
return (
|
||||
<thead>
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-nb-gray-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
type CardTableHeaderCellProps = {
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableHeaderCell({
|
||||
children,
|
||||
width,
|
||||
className,
|
||||
}: CardTableHeaderCellProps) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-left text-sm font-normal",
|
||||
className,
|
||||
)}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTableBody({ children, className }: CardTableProps) {
|
||||
return <tbody className={className}>{children}</tbody>;
|
||||
}
|
||||
|
||||
type CardTableRowProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableRow({ children, className }: CardTableRowProps) {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-nb-gray-900 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
type CardTableCellProps = {
|
||||
children: React.ReactNode;
|
||||
copy?: boolean;
|
||||
copyText?: string;
|
||||
width?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableCell({
|
||||
children,
|
||||
copy = false,
|
||||
copyText,
|
||||
width,
|
||||
className,
|
||||
}: CardTableCellProps) {
|
||||
const [, copyToClipBoard] = useCopyToClipboard(copyText ?? "");
|
||||
return (
|
||||
<td
|
||||
className={cn("px-4 py-3", className)}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-nb-gray-400 text-sm flex items-center gap-2",
|
||||
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
|
||||
)}
|
||||
onClick={() =>
|
||||
copy &&
|
||||
copyToClipBoard(`${copyText} has been copied to clipboard.`)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{copy && <Copy size={13} className={"shrink-0"} />}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
CardTable.Header = CardTableHeader;
|
||||
CardTable.HeaderCell = CardTableHeaderCell;
|
||||
CardTable.Body = CardTableBody;
|
||||
CardTable.Row = CardTableRow;
|
||||
CardTable.Cell = CardTableCell;
|
||||
|
||||
export default CardTable;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"} />
|
||||
|
||||
95
src/components/DeviceCard.tsx
Normal file
95
src/components/DeviceCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -81,7 +81,7 @@ const menuItemVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-gray-400 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
|
||||
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-nb-gray-300 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
|
||||
danger:
|
||||
"dark:focus:bg-red-900/20 dark:focus:text-red-500 dark:text-red-500",
|
||||
},
|
||||
@@ -93,25 +93,53 @@ const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "danger";
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
>(({ className, inset, variant = "default", onClick, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
|
||||
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick && onClick(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
inset,
|
||||
variant = "default",
|
||||
onClick,
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
|
||||
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (href) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick && onClick(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{href ? (
|
||||
<a href={href} target={target} rel={rel}>
|
||||
{props.children}
|
||||
</a>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
|
||||
45
src/components/ExternalLinkText.tsx
Normal file
45
src/components/ExternalLinkText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
69
src/components/HelpTooltip.tsx
Normal file
69
src/components/HelpTooltip.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
43
src/components/HoverCard.tsx
Normal file
43
src/components/HoverCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { TooltipVariants, tooltipVariants } from "./Tooltip";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> &
|
||||
TooltipVariants
|
||||
>(
|
||||
(
|
||||
{
|
||||
className = "px-4 py-2.5",
|
||||
sideOffset = 7,
|
||||
side = "top",
|
||||
variant = "default",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<HoverCardPrimitive.Portal>
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(tooltipVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
</HoverCardPrimitive.Content>
|
||||
</HoverCardPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger };
|
||||
@@ -27,6 +27,8 @@ export const linkVariants = cva(
|
||||
default: "text-netbird hover:underline font-normal",
|
||||
faded: "text-nb-gray-400 hover:text-nb-gray-300 hover:underline",
|
||||
white: "text-nb-gray-100 hover:text-white hover:underline",
|
||||
dashed:
|
||||
"text-nb-gray-100/90 underline font-normal decoration-dashed hover:text-white",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,8 +2,9 @@ import FullTooltip from "@components/FullTooltip";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AlertCircle, Eye, EyeOff } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
type InputVariants = VariantProps<typeof inputVariants>;
|
||||
|
||||
@@ -16,8 +17,9 @@ export interface InputProps
|
||||
icon?: React.ReactNode;
|
||||
error?: string;
|
||||
errorTooltip?: boolean;
|
||||
errorTooltipPosition?: "top" | "top-right";
|
||||
errorTooltipPosition?: "top" | "top-right" | "bottom";
|
||||
prefixClassName?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
}
|
||||
|
||||
const inputVariants = cva("", {
|
||||
@@ -61,10 +63,29 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
errorTooltipPosition = "top",
|
||||
variant = "default",
|
||||
prefixClassName,
|
||||
showPasswordToggle = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const isPasswordType = type === "password";
|
||||
const inputType = isPasswordType && showPassword ? "text" : type;
|
||||
|
||||
const passwordToggle =
|
||||
isPasswordType && showPasswordToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={"hover:text-white transition-all"}
|
||||
aria-label={"Toggle password visibility"}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const suffix = passwordToggle || customSuffix;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex relative h-[42px]", maxWidthClass)}>
|
||||
@@ -94,7 +115,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
</div>
|
||||
|
||||
<input
|
||||
type={type}
|
||||
type={inputType}
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
@@ -103,9 +124,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
"file:border-0",
|
||||
"focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
customPrefix && "!border-l-0 !rounded-l-none",
|
||||
customSuffix && "!pr-16",
|
||||
suffix && "!pr-16",
|
||||
icon && "!pl-10",
|
||||
"border",
|
||||
props.readOnly &&
|
||||
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
@@ -116,7 +139,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
props.disabled && "opacity-30",
|
||||
)}
|
||||
>
|
||||
{customSuffix}
|
||||
{suffix}
|
||||
</div>
|
||||
{error && errorTooltip && (
|
||||
<div
|
||||
|
||||
@@ -6,20 +6,37 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1 inline-block dark:text-nb-gray-200 flex items-center gap-2",
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-200 flex items-center gap-2",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className, "select-none")}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
type LabelProps = React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants> & {
|
||||
as?: "label" | "div";
|
||||
};
|
||||
|
||||
const Label = React.forwardRef<HTMLElement, LabelProps>(
|
||||
({ className, as = "label", children, ...props }, ref) => {
|
||||
const classes = cn(labelVariants(), className, "select-none");
|
||||
|
||||
if (as === "div") {
|
||||
return (
|
||||
<div ref={ref as React.Ref<HTMLDivElement>} className={classes}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref as React.Ref<HTMLLabelElement>}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</LabelPrimitive.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
|
||||
type PeerGroupSelectorTab = "peers" | "groups" | "resources";
|
||||
|
||||
const groupsSearchPredicate = (item: Group, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
@@ -68,14 +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) {
|
||||
@@ -258,7 +290,7 @@ export function PeerGroupSelector({
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (tab === "groups") return placeholderForSearch;
|
||||
if (tab === "resources") return "Search resource...";
|
||||
if (tab === "peers") return "Search peer...";
|
||||
if (tab === "peers") return "Search peer by name or ip...";
|
||||
return "Search...";
|
||||
}, [tab, placeholderForSearch]);
|
||||
|
||||
@@ -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>
|
||||
@@ -481,9 +537,6 @@ export function PeerGroupSelector({
|
||||
const isSelected =
|
||||
values.find((group) => group.name == option.name) !=
|
||||
undefined;
|
||||
const peerCount =
|
||||
option.peers?.length ?? option?.peers_count ?? 0;
|
||||
|
||||
const isDisabled = disabledGroups
|
||||
? disabledGroups?.findIndex(
|
||||
(g) => g.id === option.id,
|
||||
@@ -526,7 +579,7 @@ export function PeerGroupSelector({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
<div className={"flex items-center gap-4"}>
|
||||
{option?.id && showRoutes && (
|
||||
<AccessControlGroupCount group_id={option.id} />
|
||||
)}
|
||||
@@ -535,19 +588,21 @@ export function PeerGroupSelector({
|
||||
<ResourcesCounter group={option} />
|
||||
)}
|
||||
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
{policies && (
|
||||
<PolicyCounter
|
||||
group={option}
|
||||
policies={policies}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
{!users ? (
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<MonitorSmartphoneIcon
|
||||
size={14}
|
||||
className={"shrink-0"}
|
||||
showPeerCounter && (
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
{peerCount} Peer(s)
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<UsersCounter
|
||||
group={option}
|
||||
@@ -555,7 +610,6 @@ export function PeerGroupSelector({
|
||||
selected={isSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -570,7 +624,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}
|
||||
@@ -600,60 +658,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>
|
||||
);
|
||||
};
|
||||
@@ -671,7 +758,14 @@ const UsersCounter = ({
|
||||
users?.filter((user) => user.auto_groups.includes(group.id as string)) ||
|
||||
[];
|
||||
|
||||
if (usersOfGroup.length === 0) return null;
|
||||
if (usersOfGroup.length === 0)
|
||||
return (
|
||||
<span
|
||||
className={"group-hover/user-stack:text-nb-gray-200 text-nb-gray-300"}
|
||||
>
|
||||
0 User(s)
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<HorizontalUsersStack
|
||||
@@ -686,6 +780,31 @@ const UsersCounter = ({
|
||||
);
|
||||
};
|
||||
|
||||
const PeerCounter = ({
|
||||
group,
|
||||
showResourceCounter,
|
||||
}: {
|
||||
group: Group;
|
||||
showResourceCounter?: boolean;
|
||||
}) => {
|
||||
const peerCount = group.peers?.length ?? group?.peers_count ?? 0;
|
||||
const resourcesCount = group?.resources_count ?? 0;
|
||||
const hidePeerCounter =
|
||||
showResourceCounter && peerCount === 0 && resourcesCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2",
|
||||
hidePeerCounter && "hidden",
|
||||
)}
|
||||
>
|
||||
<MonitorSmartphoneIcon size={14} className={"shrink-0"} />
|
||||
{peerCount} Peer(s)
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourcesCounter = ({ group }: { group: Group }) => {
|
||||
return group?.resources_count && group.resources_count > 0 ? (
|
||||
<div
|
||||
@@ -699,6 +818,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;
|
||||
@@ -763,6 +915,7 @@ const ResourcesList = ({
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={onChange}
|
||||
estimatedItemHeight={42}
|
||||
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
|
||||
renderItem={(res) => {
|
||||
return (
|
||||
@@ -812,7 +965,8 @@ const ResourcesList = ({
|
||||
const peersSearchPredicate = (item: Peer, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return item.ip.toLowerCase().includes(lowerCaseQuery);
|
||||
if (item.ip.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return item.ipv6?.toLowerCase().includes(lowerCaseQuery) ?? false;
|
||||
};
|
||||
|
||||
const PeersList = ({
|
||||
@@ -872,6 +1026,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;
|
||||
@@ -880,7 +1035,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(
|
||||
@@ -891,7 +1046,7 @@ const PeersList = ({
|
||||
}}
|
||||
>
|
||||
<PeerOperatingSystemIcon os={res.os} />
|
||||
<TextWithTooltip text={res?.name || ""} maxChars={20} />
|
||||
<TruncatedText text={res?.name || ""} maxWidth={"270px"} />
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -31,7 +30,8 @@ const searchPredicate = (item: Peer, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
if (item.hostname.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return item.ip.toLowerCase().startsWith(lowerCaseQuery);
|
||||
if (item.ip.toLowerCase().startsWith(lowerCaseQuery)) return true;
|
||||
return !!item.ipv6?.toLowerCase().startsWith(lowerCaseQuery);
|
||||
};
|
||||
|
||||
export function PeerSelector({
|
||||
@@ -125,7 +125,6 @@ export function PeerSelector({
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]"
|
||||
}
|
||||
>
|
||||
<MapPinIcon />
|
||||
{value.ip}
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,7 +238,6 @@ export function PeerSelector({
|
||||
!isSupported && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<MapPinIcon />
|
||||
{option.ip}
|
||||
</div>
|
||||
</FullTooltip>
|
||||
|
||||
123
src/components/PinCodeInput.tsx
Normal file
123
src/components/PinCodeInput.tsx
Normal 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;
|
||||
@@ -139,7 +139,11 @@ export function PortSelector({
|
||||
<Badge
|
||||
key={x}
|
||||
variant={"gray"}
|
||||
onClick={() => toggle(x)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(x);
|
||||
}}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{x}
|
||||
@@ -184,7 +188,6 @@ export function PortSelector({
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
data-cy={"port-input"}
|
||||
typeof={"number"}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
|
||||
103
src/components/SettingCard.tsx
Normal file
103
src/components/SettingCard.tsx
Normal 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;
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@utils/helpers";
|
||||
import classNames from "classnames";
|
||||
import { ChevronDownIcon, ChevronUpIcon, DotIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
|
||||
export type SidebarItemProps = {
|
||||
@@ -36,8 +36,22 @@ export default function SidebarItem({
|
||||
labelClassName,
|
||||
visible,
|
||||
}: Readonly<SidebarItemProps>) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const path = usePathname();
|
||||
|
||||
// Check if any child route is active (for collapsible items)
|
||||
const hasActiveChild = useMemo(() => {
|
||||
if (!collapsible || !href) return false;
|
||||
return path === href || path.startsWith(href + "/");
|
||||
}, [collapsible, href, path]);
|
||||
|
||||
const [open, setOpen] = React.useState(hasActiveChild);
|
||||
|
||||
// Open the collapsible if a child route becomes active
|
||||
useEffect(() => {
|
||||
if (hasActiveChild && !open) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [hasActiveChild]);
|
||||
const router = useRouter();
|
||||
const { mobileNavOpen, toggleMobileNav, isNavigationCollapsed } =
|
||||
useApplicationContext();
|
||||
@@ -48,6 +62,7 @@ export default function SidebarItem({
|
||||
? path == href
|
||||
: path.includes(href)
|
||||
: false;
|
||||
if (collapsible && href) return;
|
||||
if (collapsible && mobileNavOpen) return;
|
||||
if (collapsible && open) return;
|
||||
if (preventRedirect) return;
|
||||
@@ -66,7 +81,7 @@ export default function SidebarItem({
|
||||
return (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Collapsible.Trigger asChild>
|
||||
<li className={"px-4 cursor-pointer list-none"}>
|
||||
<li className={"px-3 cursor-pointer list-none"}>
|
||||
<button
|
||||
className={classNames(
|
||||
"rounded-lg text-[.87rem] w-full relative font-normal",
|
||||
@@ -79,6 +94,7 @@ export default function SidebarItem({
|
||||
: "text-gray-600 hover:bg-gray-200 dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/50",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
data-cy={"left-navigation-item"}
|
||||
>
|
||||
{isChild && isNavigationCollapsed && !mobileNavOpen && (
|
||||
<div
|
||||
@@ -100,7 +116,7 @@ export default function SidebarItem({
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"px-4 whitespace-nowrap flex-1 w-full text-left",
|
||||
"px-3 whitespace-nowrap flex-1 w-full text-left",
|
||||
labelClassName,
|
||||
isNavigationCollapsed &&
|
||||
!mobileNavOpen &&
|
||||
|
||||
@@ -39,38 +39,43 @@ const Tabs = React.forwardRef<
|
||||
Tabs.displayName = TabsPrimitive.Root.displayName;
|
||||
|
||||
type TabListProps = {
|
||||
hidden?: boolean;
|
||||
justify?: "start" | "end" | "center" | "between";
|
||||
};
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & TabListProps
|
||||
>(({ className, justify = "center", ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-nowrap text-neutral-500 dark:text-nb-gray-400 w-full relative",
|
||||
className,
|
||||
justify == "center" && "justify-center justify-items-end",
|
||||
justify == "start" && "justify-start",
|
||||
justify == "end" && "justify-end",
|
||||
justify == "between" && "justify-between",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"absolute left-0 dark:bg-nb-gray-900 bg-nb-gray-100 w-full h-[1px] bottom-0 z-0"
|
||||
}
|
||||
/>
|
||||
<ScrollArea>
|
||||
<div className={"relative z-[1] flex flex-nowrap w-full "}>
|
||||
{props.children}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</TabsPrimitive.List>
|
||||
));
|
||||
>(({ className, justify = "center", hidden = false, ...props }, ref) => {
|
||||
return (
|
||||
!hidden && (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-nowrap text-neutral-500 dark:text-nb-gray-400 w-full relative",
|
||||
className,
|
||||
justify == "center" && "justify-center justify-items-end",
|
||||
justify == "start" && "justify-start",
|
||||
justify == "end" && "justify-end",
|
||||
justify == "between" && "justify-between",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"absolute left-0 dark:bg-nb-gray-900 bg-nb-gray-100 w-full h-[1px] bottom-0 z-0"
|
||||
}
|
||||
/>
|
||||
<ScrollArea>
|
||||
<div className={"relative z-[1] flex flex-nowrap w-full "}>
|
||||
{props.children}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</TabsPrimitive.List>
|
||||
)
|
||||
);
|
||||
});
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
|
||||
@@ -22,14 +22,14 @@ export const tooltipVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"bg-white dark:bg-nb-gray-940",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-930",
|
||||
"bg-nb-gray-940",
|
||||
"text-neutral-50",
|
||||
"border-neutral-200 border-nb-gray-930",
|
||||
],
|
||||
lighter: [
|
||||
"bg-white dark:bg-nb-gray-920",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-900",
|
||||
"bg-nb-gray-920",
|
||||
"text-neutral-50",
|
||||
"border-neutral-200 border-nb-gray-900",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -53,13 +53,10 @@ const TooltipContent = React.forwardRef<
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(tooltipVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
</TooltipPrimitive.Content>
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
|
||||
36
src/components/TooltipListItem.tsx
Normal file
36
src/components/TooltipListItem.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
|
||||
export const TooltipListItem = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
labelClassName,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-nb-gray-100 font-medium",
|
||||
labelClassName,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
148
src/components/VersionInfo.tsx
Normal file
148
src/components/VersionInfo.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowUpCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { VersionInfo as VersionInfoType } from "@/interfaces/Instance";
|
||||
|
||||
function formatVersion(version: string): string {
|
||||
if (!version) return "";
|
||||
// Add "v" prefix if version starts with a number
|
||||
if (/^\d/.test(version)) return `v${version}`;
|
||||
return version;
|
||||
}
|
||||
|
||||
function compareVersions(current: string, latest: string): boolean {
|
||||
// Returns true if latest is newer than current
|
||||
if (!current || !latest) return false;
|
||||
if (current === "development") return false;
|
||||
|
||||
// Strip "v" prefix if present
|
||||
const normalizedCurrent = current.replace(/^v/, "");
|
||||
const normalizedLatest = latest.replace(/^v/, "");
|
||||
|
||||
const currentParts = normalizedCurrent
|
||||
.split(".")
|
||||
.map((p) => parseInt(p, 10) || 0);
|
||||
const latestParts = normalizedLatest
|
||||
.split(".")
|
||||
.map((p) => parseInt(p, 10) || 0);
|
||||
|
||||
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
|
||||
const c = currentParts[i] || 0;
|
||||
const l = latestParts[i] || 0;
|
||||
if (l > c) return true;
|
||||
if (l < c) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const NavigationVersionInfo = () => {
|
||||
const { isNavigationCollapsed, mobileNavOpen } = useApplicationContext();
|
||||
|
||||
// Only show for self-hosted, not cloud
|
||||
if (isNetBirdHosted()) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-4 animate-fade-in",
|
||||
isNavigationCollapsed &&
|
||||
!mobileNavOpen &&
|
||||
"hidden md:group-hover/navigation:block",
|
||||
)}
|
||||
>
|
||||
<NavigationVersionInfoContent />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NavigationVersionInfoContent = () => {
|
||||
const { data: versionInfo, isLoading } = useFetchApi<VersionInfoType>(
|
||||
"/instance/version",
|
||||
true, // ignore errors
|
||||
false, // don't revalidate on focus
|
||||
);
|
||||
|
||||
const dashboardVersion =
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development";
|
||||
|
||||
if (isLoading)
|
||||
return <Skeleton height={80} className={"rounded-lg opacity-60"} />;
|
||||
|
||||
if (!versionInfo) return null;
|
||||
|
||||
// Compare versions to detect updates (returns false for "development" versions)
|
||||
const managementUpdateAvailable = compareVersions(
|
||||
versionInfo.management_current_version,
|
||||
versionInfo.management_available_version,
|
||||
);
|
||||
const dashboardUpdateAvailable = compareVersions(
|
||||
dashboardVersion,
|
||||
versionInfo.dashboard_available_version,
|
||||
);
|
||||
const hasUpdate = managementUpdateAvailable || dashboardUpdateAvailable;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-md text-xs flex flex-col gap-2 whitespace-normal border text-left",
|
||||
"bg-nb-gray-900/20 py-3 px-3 border-nb-gray-800/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1 text-nb-gray-400">
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
Latest: {formatVersion(versionInfo.management_available_version)}
|
||||
</span>
|
||||
}
|
||||
side="top"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Management</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatVersion(versionInfo.management_current_version)}
|
||||
</span>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
Latest: {formatVersion(versionInfo.dashboard_available_version)}
|
||||
</span>
|
||||
}
|
||||
side="top"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Dashboard</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatVersion(dashboardVersion)}
|
||||
</span>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
|
||||
{hasUpdate && (
|
||||
<a
|
||||
href="https://docs.netbird.io/selfhosted/maintenance/upgrade"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 text-white font-medium bg-netbird hover:bg-netbird-500 transition-colors rounded-md py-1.5 px-2 mt-1"
|
||||
>
|
||||
<ArrowUpCircle size={12} />
|
||||
<span>Update available</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationVersionInfo;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -23,13 +24,18 @@ export interface SelectOption {
|
||||
width?: number;
|
||||
country?: string;
|
||||
}>;
|
||||
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;
|
||||
@@ -41,6 +47,10 @@ interface SelectDropdownProps {
|
||||
size?: "xs" | "sm";
|
||||
children?: React.ReactNode;
|
||||
maxHeight?: number;
|
||||
triggerClassName?: string;
|
||||
iconSize?: number;
|
||||
truncate?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
@@ -48,6 +58,7 @@ export function SelectDropdown({
|
||||
value,
|
||||
disabled = false,
|
||||
popoverWidth = "auto",
|
||||
popoverMinWidth,
|
||||
options,
|
||||
showSearch = false,
|
||||
showValues = false,
|
||||
@@ -59,6 +70,10 @@ export function SelectDropdown({
|
||||
size = "sm",
|
||||
children,
|
||||
maxHeight,
|
||||
triggerClassName,
|
||||
iconSize = 14,
|
||||
truncate = false,
|
||||
compact = false,
|
||||
}: Readonly<SelectDropdownProps>) {
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
|
||||
@@ -82,7 +97,7 @@ export function SelectDropdown({
|
||||
const filteredItems = React.useMemo(() => {
|
||||
if (isEmpty(debouncedSearch)) return options;
|
||||
return options.filter((item) => {
|
||||
const value = `${item.label}${item.value}` || "";
|
||||
const value = item?.searchValue || `${item.label}${item.value}` || "";
|
||||
return value.toLowerCase().includes(debouncedSearch.toLowerCase());
|
||||
});
|
||||
}, [options, debouncedSearch]);
|
||||
@@ -98,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>
|
||||
);
|
||||
@@ -139,7 +157,11 @@ export function SelectDropdown({
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild={!children} disabled={disabled || isLoading}>
|
||||
<PopoverTrigger
|
||||
asChild={!children}
|
||||
disabled={disabled || isLoading}
|
||||
className={triggerClassName}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
@@ -147,7 +169,7 @@ export function SelectDropdown({
|
||||
variant={variant}
|
||||
disabled={disabled || isLoading}
|
||||
ref={inputRef}
|
||||
className={cn("w-full", className)}
|
||||
className={cn("w-full focus:outline-none", className)}
|
||||
>
|
||||
<div className={"w-full flex justify-between items-center gap-2"}>
|
||||
{isLoading && <Loading />}
|
||||
@@ -161,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"}
|
||||
@@ -186,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}
|
||||
/>
|
||||
@@ -226,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);
|
||||
@@ -245,25 +281,40 @@ const SelectDropdownItem = ({
|
||||
}, [isVisible]);
|
||||
|
||||
return (
|
||||
<div ref={elementRef} className={"transition-all"}>
|
||||
<div ref={elementRef} className={"transition-all w-full"}>
|
||||
{visible ? (
|
||||
<CommandItem
|
||||
value={value}
|
||||
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 flex-col text-sm font-medium",
|
||||
size === "xs" && "text-xs",
|
||||
)}
|
||||
>
|
||||
<span className={"text-nb-gray-200"}>{option.label}</span>
|
||||
</div>
|
||||
<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 w-full",
|
||||
size === "xs" && "text-xs",
|
||||
)}
|
||||
>
|
||||
<span className={"text-nb-gray-200"}>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showValue && (
|
||||
<div className={"flex items-center gap-2.5 p-1"}>
|
||||
|
||||
21
src/components/skeletons/SkeletonDeviceCard.tsx
Normal file
21
src/components/skeletons/SkeletonDeviceCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user