Compare commits

..
Author SHA1 Message Date
Viktor Liu 9a3fc271da Add SSH support for Android peers 2026-09-07 16:27:59 +02:00
71 changed files with 2946 additions and 1175 deletions

No files matched your search

-29
View File
@@ -98,24 +98,6 @@ jobs:
type=ref,event=pr
type=sha
- name: Docker meta (rootless)
id: meta_rootless
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_IMAGE }}
${{ env.GHCR_IMAGE }}
flavor: |
latest=false
suffix=-rootless,onlatest=true
tags: |
type=schedule
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=ref,event=pr
type=sha
- name: Log in to Docker Hub
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
@@ -141,18 +123,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push rootless Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.rootless
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
platforms: linux/amd64,linux/arm64,linux/arm
tags: ${{ steps.meta_rootless.outputs.tags }}
labels: ${{ steps.meta_rootless.outputs.labels }}
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
run: |
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta_rootless.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
+12 -24
View File
@@ -11,7 +11,6 @@
# ./build.sh # build + export netbird-dashboard.tar.gz
# IMAGE_TAG=v1 ./build.sh # override the image tag
# SKIP_BUILD=1 ./build.sh # reuse an existing out/ (skip npm build)
# ROOTLESS=1 ./build.sh # build the amd64-rootless image variant
#
set -euo pipefail
@@ -20,26 +19,8 @@ cd "$(dirname "$0")"
IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}"
IMAGE_TAG="${IMAGE_TAG:-amd64}"
DOCKERFILE="docker/Dockerfile"
CONTAINER_PORT=80
OUTPUT_SUFFIX=""
case "${ROOTLESS:-0}" in
0) ;;
1)
DOCKERFILE="docker/Dockerfile.rootless"
CONTAINER_PORT=8080
OUTPUT_SUFFIX="-rootless"
[[ "${IMAGE_TAG}" == *-rootless ]] || IMAGE_TAG="${IMAGE_TAG}-rootless"
;;
*)
echo "ERROR: ROOTLESS must be 0 or 1." >&2
exit 1
;;
esac
IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
OUTPUT="${OUTPUT:-${IMAGE_NAME}${OUTPUT_SUFFIX}.tar.gz}"
OUTPUT="${OUTPUT:-${IMAGE_NAME}.tar.gz}"
# Build the static export (out/) unless told to reuse an existing one. Next 16
# no longer runs ESLint during `next build`; types are checked by the build via
@@ -58,10 +39,17 @@ if [[ ! -d out ]]; then
exit 1
fi
# The .dockerignore lives under docker/, but Docker reads it from the build
# context root. Stage it for the duration of the build so node_modules/.next
# don't get shipped into the build context.
cleanup() { rm -f .dockerignore; }
trap cleanup EXIT
cp docker/.dockerignore .dockerignore
echo "==> Building ${IMAGE} for linux/amd64"
docker build \
--platform linux/amd64 \
-f "${DOCKERFILE}" \
-f docker/Dockerfile \
-t "${IMAGE}" \
.
@@ -72,13 +60,13 @@ echo "==> Done: ${OUTPUT} ($(du -h "${OUTPUT}" | cut -f1))"
echo
echo "Copy and run on the server:"
echo " scp ${OUTPUT} user@your-server:/tmp/"
echo " docker load < /tmp/${OUTPUT##*/}"
echo " docker load < /tmp/${OUTPUT}"
echo
echo "Run (nginx serves on container port ${CONTAINER_PORT}). Set the runtime env vars the"
echo "Run (nginx serves on container port 80). Set the runtime env vars the"
echo "dashboard templates at startup — for the Agent Network onboarding test,"
echo "flip NETBIRD_AGENT_NETWORK_ONLY=true:"
echo " docker run -d --name ${IMAGE_NAME} --restart unless-stopped \\"
echo " -p 8080:${CONTAINER_PORT} \\"
echo " -p 8080:80 \\"
echo " -e NETBIRD_MGMT_API_ENDPOINT=https://your-mgmt:443 \\"
echo " -e AUTH_AUTHORITY=https://your-idp/realms/netbird \\"
echo " -e AUTH_CLIENT_ID=netbird-dashboard \\"
File renamed without changes.
-29
View File
@@ -1,29 +0,0 @@
FROM alpine:3.24
RUN apk add --no-cache bash ca-certificates gettext nginx tzdata && \
rm -rf /var/cache/apk/* && \
mkdir -p /tmp/nginx
STOPSIGNAL SIGINT
EXPOSE 8080
WORKDIR /usr/share/nginx/html
COPY docker/default.conf /etc/nginx/http.d/default.conf
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/rootless.conf /etc/nginx/http.d/rootless.conf
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
COPY out/ /usr/share/nginx/html/
RUN sed -i \
-e 's/listen 80 default_server;/listen 8080 default_server;/' \
-e 's/listen \[::\]:80 default_server;/listen [::]:8080 default_server;/' \
/etc/nginx/http.d/default.conf && \
sed -i '/^user nginx;$/d' /etc/nginx/nginx.conf && \
chmod +x /usr/local/init_react_envs.sh && \
chgrp -R 0 /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx && \
chmod -R g=u /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx
USER 10001:0
ENTRYPOINT ["/usr/local/init_react_envs.sh"]
CMD ["/usr/sbin/nginx", "-e", "/dev/stderr", "-g", "pid /tmp/nginx.pid;"]
+10 -46
View File
@@ -1,59 +1,23 @@
# NetBird Dashboard
NetBird Dashboard is NetBird's management server UI.
NetBird Dashboard is NetBirds Management server UI. It allows users to signin, view setup keys and manage peers. This image is **not ready** for production use.
## Tags
```latest``` ```vX.X.X``` not available yet.
Each published image tag also has a rootless variant with the `-rootless`
suffix. For example, `main` is also published as `main-rootless`.
```main``` built on every PR being merged to the repository
## How to use this image
HTTP:
HTTP run:
```shell
docker run -d --rm -p 80:80 netbirdio/dashboard:main
docker run -d --rm -p 80:80 wiretrustee/dashboard:main
```
Using an SSL certificate from Let's Encrypt:
Using SSL certificate from Let's Encrypt®:
```shell
docker run -d --rm -p 80:80 -p 443:443 \
-e LETSENCRYPT_DOMAIN=app.mydomain.com \
-e LETSENCRYPT_EMAIL=hello@mydomain.com \
netbirdio/dashboard:main
```
For certificate generation, the server needs a public IP and a domain name
pointing to it.
### Rootless and OpenShift
The rootless image listens on port `8080`, runs as UID `10001` by default, and
supports OpenShift's arbitrary UID when the container retains GID `0`:
```shell
docker run -d --rm \
--user 12345:0 \
-p 8080:8080 \
-e AUTH_AUTHORITY=https://idp.example.com \
-e AUTH_CLIENT_ID=netbird-dashboard \
-e AUTH_AUDIENCE=netbird-dashboard \
-e AUTH_SUPPORTED_SCOPES='openid profile email' \
-e USE_AUTH0=false \
-e NETBIRD_MGMT_API_ENDPOINT=https://api.example.com \
netbirdio/dashboard:main-rootless
```
The rootless image does not include Certbot or expose HTTPS. Terminate TLS at
the OpenShift Route, Ingress, or another reverse proxy.
> For SSL generation, you need to run this image in a server with proper public IP and a domain name pointing to it.
## Environment variables
- `NGINX_SSL_PORT` changes the port that Nginx listens to. Defaults to `443`.
- `LETSENCRYPT_DOMAIN` enables Certbot for the specified domain. Defaults to
`none`.
- `LETSENCRYPT_EMAIL` is used to register the certificate request. Defaults to
`example@local`.
These certificate variables apply only to the standard image.
* ```NGINX_SSL_PORT``` Changes the port that Nginx listens to. Defaults to ```443```
* ```LETSENCRYPT_DOMAIN``` Enables Certbot`s client execution for the specified domain. Defaults to ```none```
* ```LETSENCRYPT_EMAIL``` Email used in Certbot`s client execution to register the certificate request. Defaults to ```example@local```
-6
View File
@@ -173,12 +173,6 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
rm "$f".copy
done
# When a command is provided, initialization happens before the server starts.
# The rootless image uses this path so nginx never needs to be reloaded.
if (( $# > 0 )); then
exec "$@"
fi
# Reload nginx so the patched CSP header takes effect.
# supervisord starts nginx (priority 100) before this script (priority 201) and
# never reloads it, so without this nginx keeps serving the static default.conf
-7
View File
@@ -1,7 +0,0 @@
# nginx includes this file inside the http context.
# All runtime state stays in a directory writable by the arbitrary UID used by OpenShift.
client_body_temp_path /tmp/nginx/client_temp;
proxy_temp_path /tmp/nginx/proxy_temp;
fastcgi_temp_path /tmp/nginx/fastcgi_temp;
uwsgi_temp_path /tmp/nginx/uwsgi_temp;
scgi_temp_path /tmp/nginx/scgi_temp;
+66 -52
View File
@@ -33,7 +33,7 @@
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "4.17.24",
"@types/node": "20.19.43",
"@types/node": "20.10.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-window": "^1.8.8",
@@ -64,6 +64,7 @@
"punycode": "^2.3.1",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.0",
"react-confetti-explosion": "^3.0.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.4",
"react-ga4": "^2.1.0",
@@ -82,6 +83,7 @@
"typescript": "^5"
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@playwright/test": "^1.52.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
@@ -848,6 +850,23 @@
}
}
},
"node_modules/@faker-js/faker": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz",
"integrity": "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
@@ -887,40 +906,27 @@
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -3681,12 +3687,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"version": "20.10.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz",
"integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~5.26.4"
}
},
"node_modules/@types/react": {
@@ -4610,9 +4616,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"funding": [
{
"type": "opencollective",
@@ -4629,11 +4635,11 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
"electron-to-chromium": "^1.5.402",
"node-releases": "^2.0.53",
"update-browserslist-db": "^1.3.0"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -5639,9 +5645,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.420",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
"integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==",
"version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
"license": "ISC"
},
"node_modules/elkjs": {
@@ -8424,13 +8430,10 @@
}
},
"node_modules/node-releases": {
"version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
@@ -8779,6 +8782,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -8937,9 +8941,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -9074,6 +9078,16 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-confetti-explosion": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/react-confetti-explosion/-/react-confetti-explosion-3.0.3.tgz",
"integrity": "sha512-ow5ns/1ttzXsIlbbfJmWJNiyQK8lTHBL6lRSUXGaK44K/3NIMngR57Ja96l+D6txTeFhfe0BfXGvORMxhtRDng==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-day-picker": {
"version": "9.13.2",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz",
@@ -10494,9 +10508,9 @@
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -10535,9 +10549,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"funding": [
{
"type": "opencollective",
+3 -7
View File
@@ -48,7 +48,7 @@
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "4.17.24",
"@types/node": "20.19.43",
"@types/node": "20.10.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-window": "^1.8.8",
@@ -79,6 +79,7 @@
"punycode": "^2.3.1",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.0",
"react-confetti-explosion": "^3.0.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.4",
"react-ga4": "^2.1.0",
@@ -100,6 +101,7 @@
"minimatch": ">=10.2.1"
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@playwright/test": "^1.52.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
@@ -112,11 +114,5 @@
"prettier": "3.0.3",
"tailwindcss": "^3.4.17",
"vitest": "^4.1.10"
},
"allowScripts": {
"@axa-fr/oidc-client": false,
"@axa-fr/react-oidc": false,
"fsevents": false,
"unrs-resolver": false
}
}
+7 -2
View File
@@ -70,6 +70,7 @@ import ReverseProxiesProvider, {
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { isSSHSupportedOnOS } from "@/modules/remote-access/osSupport";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
@@ -430,6 +431,7 @@ const PeerOverviewTabContent = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const { selectedGroups, setSelectedGroups } = usePeerSettings();
const isSSHSupported = isSSHSupportedOnOS(peer?.os);
return (
<div className={"px-8"}>
@@ -458,11 +460,14 @@ const PeerOverviewTabContent = () => {
</div>
)}
<PeerSSHToggle />
{isSSHSupported && <PeerSSHToggle />}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<HelpText>
Connect directly to this peer via{" "}
{isSSHSupported ? "SSH or RDP" : "RDP"}.
</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
+5 -20
View File
@@ -61,10 +61,7 @@ export default function NetBirdSettings() {
<VerticalTabs.List>
{permission.settings.read && (
<>
<VerticalTabs.Trigger
value="authentication"
data-testid="settings-tab-authentication"
>
<VerticalTabs.Trigger value="authentication" data-testid="settings-tab-authentication">
<ShieldIcon size={14} />
Authentication
</VerticalTabs.Trigger>
@@ -81,31 +78,19 @@ export default function NetBirdSettings() {
Identity Providers
</VerticalTabs.Trigger>
)}
<VerticalTabs.Trigger
value="groups"
data-testid="settings-tab-groups"
>
<VerticalTabs.Trigger value="groups" data-testid="settings-tab-groups">
<FolderGit2Icon size={14} />
Groups
</VerticalTabs.Trigger>
<VerticalTabs.Trigger
value="permissions"
data-testid="settings-tab-permissions"
>
<VerticalTabs.Trigger value="permissions" data-testid="settings-tab-permissions">
<LockIcon size={14} />
Permissions
</VerticalTabs.Trigger>
<VerticalTabs.Trigger
value="networks"
data-testid="settings-tab-networks"
>
<VerticalTabs.Trigger value="networks" data-testid="settings-tab-networks">
<NetworkIcon size={14} />
Networks
</VerticalTabs.Trigger>
<VerticalTabs.Trigger
value="clients"
data-testid="settings-tab-clients"
>
<VerticalTabs.Trigger value="clients" data-testid="settings-tab-clients">
<MonitorSmartphoneIcon size={14} />
Clients
</VerticalTabs.Trigger>
+3 -3
View File
@@ -13,7 +13,7 @@ import {
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import {
isNativeSSHSupported,
usesStandardSSHPort,
isNetbirdSSHProtocolSupported,
} from "@utils/version";
@@ -95,7 +95,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
if (isSSHConnected || isSSHConnecting) return;
connected.current = false;
try {
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const aclPort = usesStandardSSHPort(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
@@ -122,7 +122,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
connected.current = true;
try {
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const aclPort = usesStandardSSHPort(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

+3 -5
View File
@@ -1,7 +1,7 @@
import { useOidcAccessToken, useOidcIdToken } from "@axa-fr/react-oidc";
import loadConfig from "@utils/config";
import { useMemo } from "react";
import { decodeToken } from "react-jwt";
import { useJwt } from "react-jwt";
const config = loadConfig();
export const useDomainCategory = () => {
@@ -9,12 +9,10 @@ export const useDomainCategory = () => {
const { idToken } = useOidcIdToken();
const { accessToken } = useOidcAccessToken();
const token = tokenSource.toLowerCase() == "idtoken" ? idToken : accessToken;
const { decodedToken } = useJwt<Record<any, any>>(token);
const domainCategory = useMemo(() => {
try {
const decodedToken = token
? decodeToken<Record<any, any>>(token)
: undefined;
const key = decodedToken
? Object.keys(decodedToken)
.filter((key) => key.includes("wt_account_domain_category"))
@@ -24,7 +22,7 @@ export const useDomainCategory = () => {
} catch (e) {
return undefined;
}
}, [token]);
}, [decodedToken]);
const isPrivate = useMemo(() => {
return domainCategory === "private";
+20 -15
View File
@@ -1,28 +1,28 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Card from "@components/Card";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { SkeletonNotificationSettings } from "@components/skeletons/SkeletonNotificationSettings";
import { SmallBadge } from "@components/ui/SmallBadge";
import { VerticalTabs } from "@components/VerticalTabs";
import * as Tabs from "@radix-ui/react-tabs";
import { ExternalLinkIcon, MessageSquareDot } from "lucide-react";
import React from "react";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useIsLicensed } from "@/hooks/useIsLicensed";
import { VerticalTabs } from "@components/VerticalTabs";
import InlineLink from "@components/InlineLink";
import Card from "@components/Card";
import { NotificationEmailChannel } from "@/cloud/notifications/channels/NotificationEmailChannel";
import { NotificationSlackChannel } from "@/cloud/notifications/channels/NotificationSlackChannel";
import { NotificationWebhookChannel } from "@/cloud/notifications/channels/NotificationWebhookChannel";
import { NotificationChannelListItem } from "@/cloud/notifications/NotificationChannelListItem";
import useUrlTab from "@/hooks/useUrlTab";
import Paragraph from "@components/Paragraph";
import NotificationProvider, {
useNotifications,
} from "@/cloud/notifications/NotificationProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useIsLicensed } from "@/hooks/useIsLicensed";
import useUrlTab from "@/hooks/useUrlTab";
import { NotificationChannelListItem } from "@/cloud/notifications/NotificationChannelListItem";
import {
NOTIFICATION_CHANNELS_DOCS_LINK,
NotificationChannelType,
} from "@/interfaces/NotificationChannel";
import { SkeletonNotificationSettings } from "@components/skeletons/SkeletonNotificationSettings";
import { NotificationSlackChannel } from "@/cloud/notifications/channels/NotificationSlackChannel";
import { SmallBadge } from "@components/ui/SmallBadge";
const NotificationsOverview = ({
onSelectChannel,
@@ -52,18 +52,23 @@ const NotificationsOverview = ({
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between -mt-1"}>
<div className={"flex items-start justify-between"}>
<div>
<h1>Notifications</h1>
<Paragraph className={"block"}>
Get notified when important events happen.{" "}
<Paragraph>
Choose how to be notified when important events occur in your
account.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink
href={NOTIFICATION_CHANNELS_DOCS_LINK}
target={"_blank"}
>
Learn more
Notification Channels
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
</div>
@@ -1,140 +0,0 @@
import Button from "@components/Button";
import { Input } from "@components/Input";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import { validator } from "@utils/helpers";
import { PlusCircle } from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useSignInDomains } from "@/cloud/sign-in-domains/useSignInDomains";
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
import { DomainVerificationModal } from "@/modules/integrations/sso/DomainVerificationModal";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export const AddSignInDomainModal = ({ open, onOpenChange }: Props) => {
const { addDomain, verifyDomain, mutate } = useSignInDomains();
const [name, setName] = useState("");
const [verifyModal, setVerifyModal] = useState(false);
const [addedDomain, setAddedDomain] = useState<SignInDomain>();
const validationError = useMemo(() => {
const domain = name.trim();
if (domain === "") return "";
const valid = validator.isValidDomain(domain, {
allowWildcard: false,
allowOnlyTld: false,
preventLeadingAndTrailingDots: true,
});
if (!valid) return "Please enter a valid domain, e.g. company.com";
return "";
}, [name]);
const setOpen = (next: boolean) => {
if (!next) setName("");
onOpenChange(next);
};
const submit = () => {
const domain = name.trim();
if (!domain || validationError) return;
setOpen(false);
notify({
title: "Sign-in Domains",
description: `${domain} has been added`,
promise: addDomain(domain).then((added) => {
if (
added &&
added.validation_status !== DomainValidationStatus.VERIFIED
) {
setAddedDomain(added);
setVerifyModal(true);
}
mutate().catch(() => {});
return added;
}),
loadingMessage: "Adding domain...",
});
};
return (
<>
{addedDomain && (
<DomainVerificationModal
open={verifyModal}
onOpenChange={setVerifyModal}
domain={addedDomain.name}
token={addedDomain.validation_token}
onVerify={() =>
verifyDomain(addedDomain.id).then((res) => {
mutate().catch(() => {});
return res;
})
}
/>
)}
<Modal open={open} onOpenChange={setOpen}>
<ModalContent maxWidthClass={"max-w-md"}>
<ModalHeader
className={"pb-4 px-8"}
title={"Add Sign-in Domain"}
description={"Users on this domain are matched to your account."}
/>
<form
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<div className={"p-default flex flex-col gap-4"}>
<div>
<Input
autoFocus
data-testid={"add-domain-input"}
placeholder={"e.g. company.com"}
value={name}
error={validationError}
onChange={(event) => setName(event.target.value)}
/>
</div>
</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"}
type={"submit"}
disabled={!name.trim() || !!validationError}
data-testid={"add-domain-submit"}
>
<PlusCircle size={16} />
Add
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
</Modal>
</>
);
};
@@ -1,72 +0,0 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Label } from "@components/Label";
import { cn } from "@utils/helpers";
import { isNetBirdCloud } from "@utils/netbird";
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { useDomainCategory } from "@/cloud/cloud-hooks/useDomainCategory";
import { AddSignInDomainModal } from "@/cloud/sign-in-domains/AddSignInDomainModal";
import SignInDomainsTable from "@/cloud/sign-in-domains/table/SignInDomainsTable";
import { useSignInDomains } from "@/cloud/sign-in-domains/useSignInDomains";
import { usePermissions } from "@/contexts/PermissionsProvider";
const POLL_INTERVAL_MS = 15_000;
const SIGN_IN_DOMAINS_DOCS_LINK =
"https://docs.netbird.io/manage/team/sign-in-domains";
export const useCanViewSignInDomains = () => {
const { permission } = usePermissions();
const { isPrivate } = useDomainCategory();
return Boolean(permission?.settings?.read) && isNetBirdCloud() && isPrivate;
};
export const SignInDomainsSettings = () => {
const { permission } = usePermissions();
const { domains, isLoading, isUnavailable } =
useSignInDomains(POLL_INTERVAL_MS);
const [addModal, setAddModal] = useState(false);
const showSkeleton = isLoading && !isUnavailable;
return (
<div
className={cn(
isLoading && "animate-pulse pointer-events-none opacity-90",
isUnavailable && "pointer-events-none opacity-70",
)}
data-testid={"sign-in-domains"}
>
<div className={"flex items-end justify-between gap-4 mb-4"}>
<div className={"min-w-0"}>
<Label>Sign-in Domains</Label>
<HelpText className={"!mb-0"}>
Users from these domains can join your account.{" "}
<InlineLink href={SIGN_IN_DOMAINS_DOCS_LINK} target={"_blank"}>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</HelpText>
</div>
<Button
variant={"primary"}
size={"xs"}
className={"shrink-0"}
disabled={!permission?.settings?.update || isUnavailable}
onClick={() => setAddModal(true)}
data-testid={"add-domain"}
>
<PlusCircle size={16} />
Add
</Button>
</div>
<SignInDomainsTable domains={domains} isLoading={showSkeleton} />
<AddSignInDomainModal open={addModal} onOpenChange={setAddModal} />
</div>
);
};
@@ -1,121 +0,0 @@
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { notify } from "@components/Notification";
import { cn } from "@utils/helpers";
import { TrashIcon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { useSignInDomains } from "@/cloud/sign-in-domains/useSignInDomains";
import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
import { DomainVerificationModal } from "@/modules/integrations/sso/DomainVerificationModal";
type Props = {
domain: SignInDomain;
};
export default function DomainActionCell({ domain }: Readonly<Props>) {
const { permission } = usePermissions();
const { verifyDomain, deleteDomain, mutate } = useSignInDomains();
const { confirm } = useDialog();
const [modal, setModal] = useState(false);
const isVerified =
domain.validation_status === DomainValidationStatus.VERIFIED;
const connections = domain.connections ?? [];
const removeBlockedReason: React.ReactNode = domain.is_primary ? (
<>
This is the primary domain of your account
<br />
and cannot be removed.
</>
) : connections.length > 0 ? (
<>
This domain is used by an SSO integration.
<br />
Remove it there before deleting it.
</>
) : undefined;
const deleteDomainHandler = async () => {
const choice = await confirm({
title: `Remove ${domain.name}?`,
description:
"Users with an email address on this domain will no longer be matched to your account.",
confirmText: "Remove",
cancelText: "Cancel",
type: "danger",
maxWidthClass: "max-w-md",
});
if (!choice) return;
notify({
title: "Sign-in Domains",
description: `${domain.name} has been removed`,
promise: deleteDomain(domain.id).then((res) => {
mutate().catch(() => {});
return res;
}),
loadingMessage: "Removing domain...",
});
};
return (
<div
className={
"relative top-[2px] flex gap-2 items-center justify-end ml-auto min-h-[34px]"
}
>
<DomainVerificationModal
open={modal}
onOpenChange={setModal}
domain={domain.name}
token={domain.validation_token}
onVerify={() =>
verifyDomain(domain.id).then((res) => {
mutate().catch(() => {});
return res;
})
}
/>
{!isVerified && (
<Button
variant={"secondary"}
size={"xs"}
className={"!px-3"}
disabled={!permission.settings.update}
onClick={() => setModal(true)}
data-testid={"verify-domain"}
>
Verify
</Button>
)}
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
{removeBlockedReason ?? "Remove domain"}
</div>
}
interactive={false}
>
<Button
variant={"danger-outline"}
size={"xs"}
className={cn(
"!p-0 !h-[34px] !w-[34px]",
removeBlockedReason && "pointer-events-none",
)}
disabled={!!removeBlockedReason || !permission.settings.update}
onClick={deleteDomainHandler}
aria-label={"Remove domain"}
data-testid={"remove-domain"}
>
<TrashIcon size={14} />
</Button>
</FullTooltip>
</div>
);
}
@@ -1,46 +0,0 @@
import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers";
import * as React from "react";
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
type Props = {
domain: SignInDomain;
};
const FAILED_HINT =
"This domain could not be verified. Check that the TXT record is published, then try again. If it keeps failing, contact support@netbird.io";
export default function DomainStatusCell({ domain }: Readonly<Props>) {
const status = domain.validation_status;
const isVerified = status === DomainValidationStatus.VERIFIED;
const isPending = status === DomainValidationStatus.PENDING;
const isFailed = status === DomainValidationStatus.FAILED;
return (
<FullTooltip
content={<div className={"text-xs max-w-xs"}>{FAILED_HINT}</div>}
disabled={!isFailed}
interactive={false}
>
<div
className={"flex items-center gap-2 text-sm text-nb-gray-300 mr-auto"}
>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
isVerified && "bg-green-400",
isPending && "bg-yellow-400",
!isVerified && !isPending && "bg-red-500",
)}
/>
{statusLabel(status)}
</div>
</FullTooltip>
);
}
function statusLabel(status: DomainValidationStatus) {
if (status === DomainValidationStatus.VERIFIED) return "Verified";
if (status === DomainValidationStatus.PENDING) return "Pending";
return "Unverified";
}
@@ -1,110 +0,0 @@
import Card from "@components/Card";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import NoResults from "@components/ui/NoResults";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import * as React from "react";
import { useState } from "react";
import Skeleton from "react-loading-skeleton";
import DomainActionCell from "@/cloud/sign-in-domains/table/DomainActionCell";
import DomainStatusCell from "@/cloud/sign-in-domains/table/DomainStatusCell";
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
type Props = {
domains?: SignInDomain[];
isLoading?: boolean;
};
const PLACEHOLDER_ID = "__loading__";
const placeholderRow: SignInDomain = {
id: PLACEHOLDER_ID,
name: "",
is_primary: false,
validation_token: "",
validation_status: DomainValidationStatus.PENDING,
validation_last_updated: null,
};
const isPlaceholder = (domain: SignInDomain) => domain.id === PLACEHOLDER_ID;
const SignInDomainsColumns: ColumnDef<SignInDomain>[] = [
{
header: ({ column }) => {
return <DataTableHeader column={column}>Domain</DataTableHeader>;
},
accessorKey: "name",
sortingFn: "text",
cell: ({ row }) =>
isPlaceholder(row.original) ? (
<Skeleton height={10} width={"60%"} />
) : (
<div className={"text-sm text-nb-gray-100"}>{row.original.name}</div>
),
},
{
header: ({ column }) => {
return <DataTableHeader column={column}>Status</DataTableHeader>;
},
accessorKey: "validation_status",
sortingFn: "text",
cell: ({ row }) =>
isPlaceholder(row.original) ? (
<Skeleton height={10} width={"50%"} />
) : (
<DomainStatusCell domain={row.original} />
),
},
{
accessorKey: "id",
header: () => null,
enableSorting: false,
cell: ({ row }) =>
isPlaceholder(row.original) ? (
<div className={"flex items-center justify-end ml-auto min-h-[34px]"} />
) : (
<DomainActionCell domain={row.original} />
),
},
];
export default function SignInDomainsTable({
domains,
isLoading,
}: Readonly<Props>) {
const [sorting, setSorting] = useState<SortingState>([
{
id: "name",
desc: false,
},
]);
return (
<DataTable
wrapperComponent={Card}
wrapperProps={{ className: "w-full pb-1" }}
useRowId={true}
sorting={sorting}
setSorting={setSorting}
minimal={true}
showSearchAndFilters={false}
inset={false}
tableClassName={"mt-0"}
text={"Domains"}
columns={SignInDomainsColumns}
keepStateInLocalStorage={false}
data={isLoading ? [placeholderRow] : domains}
getStartedCard={
<NoResults
className={"py-4"}
hideIcon
title={"No sign-in domains yet"}
description={
"Add a domain, then verify ownership with a DNS record to start matching users to this account."
}
/>
}
paginationPaddingClassName={"px-0 pt-8"}
/>
);
}
Loaded 20 of 71 files, more files were not shown because too many files have changed in this diff. Show more