diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1767161..eea8c22 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,3 +10,10 @@ Select exactly one: Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ + +## E2E tests +Optional: override the image tags used by the Playwright e2e workflow. +Defaults to `main` when omitted. + +management-cloud-tag: main +reverse-proxy-tag: main diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index fcf415e..b88100a 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -7,17 +7,24 @@ on: - "**" pull_request: +# Cancel in-progress runs on the same ref (PR or branch) when a new commit +# arrives, so we don't waste CI building superseded commits. +concurrency: + group: build-and-push-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: - IMAGE_NAME: netbirdio/dashboard + DOCKERHUB_IMAGE: netbirdio/dashboard + GHCR_IMAGE: ghcr.io/netbirdio/dashboard-cloud jobs: build_n_push: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: setup-node - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: '20' cache: 'npm' @@ -69,25 +76,43 @@ jobs: NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }} - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Docker meta id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: - images: ${{ env.IMAGE_NAME }} - - - name: Login to DockerHub - uses: docker/login-action@v2 + images: | + ${{ env.DOCKERHUB_IMAGE }} + ${{ env.GHCR_IMAGE }} + flavor: | + latest=false + 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 + uses: docker/login-action@v3 with: username: ${{ secrets.NB_DOCKER_USER }} password: ${{ secrets.NB_DOCKER_TOKEN }} - - - name: Docker build and push - uses: docker/build-push-action@v3 + + - name: Log in to the GitHub Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 with: context: . file: docker/Dockerfile @@ -95,3 +120,7 @@ jobs: platforms: linux/amd64,linux/arm64,linux/arm tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + - run: | + echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY + echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 0000000..8bf45fc --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,162 @@ +name: Playwright E2E Tests +on: + push: + branches: [main] + pull_request: + # `edited` is included so that updating the PR description (e.g. to set + # `management-cloud-tag: ` or `reverse-proxy-tag: `) re-triggers + # the e2e run with the new tag. + types: [opened, synchronize, reopened, edited] + workflow_dispatch: + inputs: + management-cloud-tag: + description: 'Management Cloud image tag' + required: true + type: string + default: 'main' + reverse-proxy-tag: + description: 'Reverse Proxy image tag' + required: true + type: string + default: 'main' + +env: + REGISTRY: ghcr.io + +jobs: + playwright-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + fetch-depth: 0 + + - name: setup-node + uses: actions/setup-node@v5 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Log in to the Container registry + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.CI_DOCKER_PULL_GITHUB_TOKEN }} + + - run: echo '{}' > .local-config.json + + - name: Install jq + run: sudo apt-get install jq + + - name: Resolve management-cloud image tag + id: management_tag + env: + INPUT_TAG: ${{ inputs.management-cloud-tag }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + # Use workflow_dispatch input if provided, otherwise parse PR body. + # Falls back to `main` when not specified. + if [ -n "$INPUT_TAG" ]; then + TAG="$INPUT_TAG" + else + TAG=$(printf '%s' "$PR_BODY" \ + | grep -iE '^[[:space:]]*management-cloud-tag:[[:space:]]*[A-Za-z0-9._-]+[[:space:]]*$' \ + | head -n1 \ + | sed -E 's/^[[:space:]]*[Mm]anagement-cloud-tag:[[:space:]]*([A-Za-z0-9._-]+)[[:space:]]*$/\1/' \ + || true) + if [ -z "$TAG" ]; then + TAG="main" + fi + fi + echo "Using management-cloud tag: $TAG" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Resolve reverse-proxy image tag + id: reverse_proxy_tag + env: + INPUT_TAG: ${{ inputs.reverse-proxy-tag }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + if [ -n "$INPUT_TAG" ]; then + TAG="$INPUT_TAG" + else + TAG=$(printf '%s' "$PR_BODY" \ + | grep -iE '^[[:space:]]*reverse-proxy-tag:[[:space:]]*[A-Za-z0-9._-]+[[:space:]]*$' \ + | head -n1 \ + | sed -E 's/^[[:space:]]*[Rr]everse-proxy-tag:[[:space:]]*([A-Za-z0-9._-]+)[[:space:]]*$/\1/' \ + || true) + if [ -z "$TAG" ]; then + TAG="main" + fi + fi + echo "Using reverse-proxy tag: $TAG" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Setup test environment + env: + MANAGEMENT_IMAGE_TAG: ${{ steps.management_tag.outputs.tag }} + REVERSE_PROXY_IMAGE_TAG: ${{ steps.reverse_proxy_tag.outputs.tag }} + run: cd ./e2e/environment && bash create-test-env.sh + + - name: Run Playwright tests + id: playwright + run: | + set -o pipefail + npm run test:ci 2>&1 | tee playwright-output.log + + - name: Append Playwright summary to job summary + if: always() && hashFiles('e2e/test-results/results.json') != '' + run: | + if [ -f e2e/test-results/results.json ]; then + passed=$(jq '.stats.expected // 0' e2e/test-results/results.json) + failed=$(jq '.stats.unexpected // 0' e2e/test-results/results.json) + skipped=$(jq '.stats.skipped // 0' e2e/test-results/results.json) + duration=$(jq '.stats.duration // 0' e2e/test-results/results.json) + { + echo '### Playwright results' + echo '' + echo "| Passed | Failed | Skipped | Duration |" + echo "|--------|--------|---------|----------|" + echo "| $passed | $failed | $skipped | ${duration}ms |" + } >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Collect container logs + if: failure() + run: | + cd e2e/environment + docker compose logs management --tail=500 --no-color > management.log 2>&1 || true + docker compose logs reverse-proxy --tail=500 --no-color > reverse-proxy.log 2>&1 || true + + - uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: e2e/playwright-report/ + + - uses: actions/upload-artifact@v7 + if: ${{ failure() }} + with: + name: playwright-traces + path: e2e/test-results/ + + - uses: actions/upload-artifact@v7 + if: ${{ failure() }} + with: + name: management-logs + path: e2e/environment/management.log + + - uses: actions/upload-artifact@v7 + if: ${{ failure() }} + with: + name: reverse-proxy-logs + path: e2e/environment/reverse-proxy.log diff --git a/.gitignore b/.gitignore index 86420ac..57bccfe 100644 --- a/.gitignore +++ b/.gitignore @@ -36,9 +36,14 @@ yarn-error.log* next-env.d.ts # config -.local-config.json +.local*config*.json .test-config.json -cypress.env.json +e2e/playwright.env.json +e2e/fixtures/auth/*.json +e2e/test-results/ +e2e/playwright-report/ +/test-results/ +/playwright-report/ .configs/.local-config.zitadel.json .configs/.staging-config.json .configs/.temp-config.json diff --git a/AUTHORS b/AUTHORS index 27eb796..565d2e6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,3 +1,3 @@ Mikhail Bragin (https://github.com/braginini) Maycon Santos (https://github.com/mlsmaycon) -Wiretrustee UG (haftungsbeschränkt) \ No newline at end of file +NetBird GmbH \ No newline at end of file diff --git a/announcements.json b/announcements.json index 6c3e4d5..13b4bfd 100644 --- a/announcements.json +++ b/announcements.json @@ -1,8 +1,8 @@ [ { - "tag": "New", - "text": "IPv6 Overlay Addressing - Peers can now receive both IPv4 and IPv6 overlay addresses, with full DNS and routing support.", - "link": "https://netbird.io/knowledge-hub/ipv6-overlay-addressing", + "tag": "Preview", + "text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.", + "link": "https://github.com/netbirdio/netbird/discussions/6483", "linkText": "Learn more", "variant": "important", "isExternal": true, diff --git a/config.json b/config.json index 021bbfd..8976b53 100644 --- a/config.json +++ b/config.json @@ -14,5 +14,13 @@ "hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID", "googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID", "googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID", - "wasmPath": "$NETBIRD_WASM_PATH" + "authServiceUrl": "$NETBIRD_AUTH_SERVICE_URL", + "wasmPath": "$NETBIRD_WASM_PATH", + "licensed": "$NETBIRD_LICENSED", + "cloud": "$NETBIRD_CLOUD", + "hubspotPortalId": "$NETBIRD_HUBSPOT_PORTAL_ID", + "hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID", + "hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID", + "hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID", + "analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS" } diff --git a/cypress.config.ts b/cypress.config.ts deleted file mode 100644 index b75915a..0000000 --- a/cypress.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "cypress"; - -export default defineConfig({ - e2e: { - baseUrl: "http://localhost:3000", - }, - component: { - devServer: { - framework: "next", - bundler: "webpack", - }, - }, - viewportWidth: 1920, - viewportHeight: 1080, -}); diff --git a/cypress/e2e/test.cy.ts b/cypress/e2e/test.cy.ts deleted file mode 100644 index 5e3214f..0000000 --- a/cypress/e2e/test.cy.ts +++ /dev/null @@ -1,13 +0,0 @@ -describe("Click all tabs in peer modal", () => { - it("passes", () => { - cy.visit("/install"); - cy.get("div").contains("Linux").click(); - cy.get("[data-cy=copy-to-clipboard]").click(); - cy.get("div").contains("Windows").click(); - cy.get("[data-cy=copy-to-clipboard]").click(); - cy.get("div").contains("Android").click(); - cy.get("[data-cy=copy-to-clipboard]").click(); - cy.get("div").contains("Docker").click(); - cy.get("[data-cy=copy-to-clipboard]").click(); - }); -}); diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json deleted file mode 100644 index 02e4254..0000000 --- a/cypress/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" -} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts deleted file mode 100644 index 698b01a..0000000 --- a/cypress/support/commands.ts +++ /dev/null @@ -1,37 +0,0 @@ -/// -// *********************************************** -// This example commands.ts shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -// -// declare global { -// namespace Cypress { -// interface Chainable { -// login(email: string, password: string): Chainable -// drag(subject: string, options?: Partial): Chainable -// dismiss(subject: string, options?: Partial): Chainable -// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable -// } -// } -// } \ No newline at end of file diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts deleted file mode 100644 index f80f74f..0000000 --- a/cypress/support/e2e.ts +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/e2e.ts is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands' - -// Alternatively you can use CommonJS syntax: -// require('./commands') \ No newline at end of file diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json deleted file mode 100644 index 230dbb5..0000000 --- a/cypress/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "compilerOptions": { - "target": "es5", - "lib": ["es5", "dom"], - "baseUrl": "http://localhost:3000", - "types": ["cypress", "node"], - }, - "include": ["**/*.ts"] -} \ No newline at end of file diff --git a/docker/default.conf b/docker/default.conf index 4549f93..131d29c 100644 --- a/docker/default.conf +++ b/docker/default.conf @@ -14,14 +14,24 @@ server { location / { try_files $uri $uri.html $uri/ =404; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always; + add_header Last-Modified ""; expires off; } error_page 404 /404.html; location = /404.html { internal; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always; + add_header Last-Modified ""; expires off; } } \ No newline at end of file diff --git a/docker/init_react_envs.sh b/docker/init_react_envs.sh index d1c5a18..7a666d6 100644 --- a/docker/init_react_envs.sh +++ b/docker/init_react_envs.sh @@ -61,12 +61,95 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID} export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID} export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken} export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false} +export NETBIRD_AUTH_SERVICE_URL=${NETBIRD_AUTH_SERVICE_URL} export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH} +export NETBIRD_CSP=${NETBIRD_CSP} +export NETBIRD_LICENSED=${NETBIRD_LICENSED:-false} +export NETBIRD_CLOUD=${NETBIRD_CLOUD:-false} +export NETBIRD_HUBSPOT_PORTAL_ID=${NETBIRD_HUBSPOT_PORTAL_ID} +export NETBIRD_HUBSPOT_SIGNUP_FORM_ID=${NETBIRD_HUBSPOT_SIGNUP_FORM_ID} +export NETBIRD_HUBSPOT_ONBOARDING_FORM_ID=${NETBIRD_HUBSPOT_ONBOARDING_FORM_ID} +export NETBIRD_HUBSPOT_SURVEY_FORM_ID=${NETBIRD_HUBSPOT_SURVEY_FORM_ID} +export NETBIRD_ANALYTICS_EXCLUDED_EMAILS=${NETBIRD_ANALYTICS_EXCLUDED_EMAILS} echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}" +# Build CSP +FIRST_PARTY_CSP="pkgs.netbird.io" +FIRST_PARTY_CSP_CONNECT_SRC="wss://*.netbird.io" +THIRD_PARTY_CSP="*.licdn.com *.linkedin.com *.vector.co *.sibforms.com *.hotjar.com *.hotjar.io *.redditstatic.com pixel-config.reddit.com *.clarity.ms c.bing.com *.microsoft.com googleads.g.doubleclick.net pagead2.googlesyndication.com www.google.com www.googleadservices.com *.google-analytics.com *.googletagmanager.com analytics.google.com *.hubapi.com *.hs-banner.com *.hubspot.com *.hubspot.net js.hs-analytics.com *.hsforms.net *.hscollectedforms.net *.hs-analytics.net *.hsforms.com track.hubspot.com *.hsadspixel.net static.hsappstatic.net" +THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com" +THIRD_PARTY_CSP_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com" + +CSP_DOMAINS="" +CSP_DOMAINS_CONNECT_SRC="" + +if [[ -n "${NETBIRD_CSP}" ]]; then + CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_CSP" +fi + +# Add AUTH_AUTHORITY to CSP +if [[ -n "${AUTH_AUTHORITY}" ]]; then + CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY" +fi + +# Add AUTH_AUDIENCE to CSP +if [[ -n "${AUTH_AUDIENCE}" && ("${AUTH_AUDIENCE}" == *"http://"* || "${AUTH_AUDIENCE}" == *"https://"*) ]]; then + CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUDIENCE" +fi + +# Add NETBIRD_AUTH_SERVICE_URL to CSP +if [[ -n "${NETBIRD_AUTH_SERVICE_URL}" ]]; then + CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_AUTH_SERVICE_URL" +fi + +# Add NETBIRD_MGMT_API_ENDPOINT to CSP +if [[ -n "${NETBIRD_MGMT_API_ENDPOINT}" ]]; then + MGMT_DOMAIN=$(echo "$NETBIRD_MGMT_API_ENDPOINT" | sed -E 's|https?://||' | cut -d'/' -f1 | cut -d':' -f1) + if [[ -n "$MGMT_DOMAIN" ]]; then + if [[ "$NETBIRD_MGMT_API_ENDPOINT" == https://* ]]; then + CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$MGMT_DOMAIN" + elif [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* ]]; then + CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$MGMT_DOMAIN" + fi + fi +fi + +# Add LETSENCRYPT_DOMAIN to CSP +if [[ -n "${LETSENCRYPT_DOMAIN}" ]]; then + if [[ "$LETSENCRYPT_DOMAIN" == *"localhost"* ]]; then + CSP_DOMAINS="$CSP_DOMAINS http://$LETSENCRYPT_DOMAIN" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$LETSENCRYPT_DOMAIN" + else + CSP_DOMAINS="$CSP_DOMAINS https://$LETSENCRYPT_DOMAIN" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$LETSENCRYPT_DOMAIN" + fi +fi + +CSP_CONNECT_SRC="$CSP_DOMAINS $CSP_DOMAINS_CONNECT_SRC $FIRST_PARTY_CSP $FIRST_PARTY_CSP_CONNECT_SRC $THIRD_PARTY_CSP $THIRD_PARTY_CSP_CONNECT_SRC" +CSP_FRAME_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP" +CSP_SCRIPT_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP $THIRD_PARTY_CSP_SCRIPT_SRC" + +# Remove duplicates +CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//') +CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//') +CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//') + +# Update CSP in nginx config +CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;" +CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;" + +echo "CSP header: $CSP_HEADER" + +# Replace CSP header in nginx config +sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /etc/nginx/http.d/default.conf || { + echo "Failed to replace CSP header" +} + # replace ENVs in the config -ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH" +ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS" OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js" envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS" diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md new file mode 100644 index 0000000..76a3ba8 --- /dev/null +++ b/e2e/CLAUDE.md @@ -0,0 +1,233 @@ +# Playwright E2E Testing Guide + +Complete reference for writing, running, and debugging Playwright E2E tests in the NetBird Dashboard. + +## Philosophy + +Tests simulate real user behavior: navigate via sidebar, click buttons, type into inputs, verify outcomes on screen. Use `{ force: true }` for Radix modal pointer-events issues. + +## Setup & Running + +```bash +npm run test:setup # Create docker-based test environment with Zitadel +npm run test:dev # Start app in test mode on http://localhost:1337 +npm run test # Run all e2e tests headless +npm run test:ui # Open Playwright interactive UI +npx playwright test --config=e2e/playwright.config.ts tests/networks.spec.ts # Single spec +npm run test:clean # Tear down test environment +``` + +Config: `e2e/playwright.config.ts` (baseURL: `http://localhost:1337`). Auth: `e2e/playwright.env.json` (gitignored). + +### Config Details + +- `fullyParallel: false` — tests run sequentially within each spec +- Workers: 2 in CI, 4 locally +- Retries: 1 +- Viewport: 1920x1080 +- Timeouts: action 10s, navigation 15s +- On failure: screenshot, trace, video retained + +## File Structure + +``` +e2e/ + playwright.config.ts + helpers/ + fixtures.ts # dashboardAsOwner / dashboardAsUser fixtures + auth.ts # loginToApp(), navigateTo() + navigation.ts # visitByNavigation() + utils.ts # generateRandomName(), clearScrollLock() + api.ts # Direct REST API helpers (list/delete for all entities) + reverse-proxy-l4.ts # Shared L4 reverse proxy helpers + fixtures/auth/ # Generated storageState files (gitignored) + environment/ # Docker compose, setup/teardown scripts + tests/ + login.spec.ts # Auth setup (login both users, save storageState) + *.spec.ts # Test specs +``` + +## Architecture + +Auth is handled by `login.spec.ts`, which runs as a separate Playwright project (`"login"`) that all other tests depend on via `dependencies: ["login"]` in the config. It logs in both users and saves Zitadel session cookies to `fixtures/auth/`. If auth files already exist, login is skipped. Each test file that modifies shared state (e.g., user roles) must restore it before finishing. + +## Authentication + +Two test users authenticated via the `login` project, saved as `storageState`: + +| User | File | Role | Usage | +|------|------|------|-------| +| owner | `fixtures/auth/owner.json` | Owner | Default for all tests | +| user | `fixtures/auth/user.json` | User (changeable) | Role-based testing | + +### Custom Fixtures (`helpers/fixtures.ts`) + +Tests use custom fixtures instead of raw `page`: + +```typescript +import { test, expect } from "../helpers/fixtures"; + +test("example", async ({ dashboardAsOwner: page }) => { + // Pre-authenticated as owner, reused across worker +}); + +test("multi-user", async ({ dashboardAsUser: page }) => { + // Pre-authenticated as user +}); +``` + +- `dashboardAsOwner` — Pre-authenticated Page for the owner user (worker-scoped, reused across tests) +- `dashboardAsUser` — Pre-authenticated Page for the user user (worker-scoped) + +For multi-context scenarios (e.g., approval/billing tests), create a new browser context directly: + +```typescript +const context = await browser.newContext({ storageState: "e2e/fixtures/auth/user.json" }); +const page = await context.newPage(); +``` + +## Helpers Reference + +### `auth.ts` +- **`loginToApp(page, user?)`** — Full Zitadel OIDC login flow. Handles app ready, setup modal, approval pending, onboarding, account selection, and login form states. +- **`navigateTo(page, path)`** — `page.goto(path)` + dismisses setup modal if present + clears scroll-lock. + +### `navigation.ts` +- **`visitByNavigation(page, navText)`** — Clicks sidebar items by exact text via `left-navigation-item` testid. + +### `utils.ts` +- **`generateRandomName(prefix?)`** — Returns `prefix` + 7 random alphanumeric chars. +- **`clearScrollLock(page)`** — Removes Radix artifacts: `data-scroll-locked`, `pointer-events: none`, stale overlay divs. + +### `api.ts` +Direct REST API helpers that extract Bearer tokens from intercepted responses. Used for cleanup (deleting test artifacts by prefix). Covers: groups, networks, policies, routes, setup keys, DNS zones, nameserver groups, notification channels, reverse proxy services, users. + +Pattern: `listX(page)` / `deleteXById(page, id)` / `deleteXByPrefix(page, prefix)` + +### `reverse-proxy-l4.ts` +Shared helpers for TCP/TLS/UDP reverse proxy service tests: +- **`createNetwork(page)`** — Creates network, returns name +- **`addResource(page, networkName, address)`** — Adds resource to a network +- **`selectL4Resource(page, resourceName)`** — Selects resource in L4 target dropdown +- **`addAccessControlRules(page)`** / **`removeAllAccessControlRules(page)`** — Manages standard test rules +- **`resetServiceFilters(page)`** — Clicks "Reset Filters & Search" button if visible +- **`openServiceEdit(page, subdomain)`** — Navigates to services, resets filters, opens edit modal +- **`deleteService(page, subdomain)`** — Deletes service via action dropdown +- **`saveServiceEdit(page)`** — Saves with "No Protection" confirmation handling +- **`deleteNetwork(page, networkName)`** — Navigates to networks and deletes by name + +## Writing Tests + +### Standard Structure + +```typescript +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; + +test.describe.serial("Feature Name", () => { + test("Should create an item", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/feature-page"); + const name = generateRandomName("prefix-"); + // ... create item + }); + + test("Should delete the item", async ({ dashboardAsOwner: page }) => { + // ... cleanup + }); +}); +``` + +### Key Patterns + +**Selectors** — Always use `data-testid` via `page.getByTestId()`: +```typescript +page.getByTestId("group-name-input") // [data-testid="group-name-input"] +page.getByTestId("confirmation.confirm") // Confirmation dialogs +``` + +**Text matching:** +```typescript +page.getByText("Some text") +page.locator("tr").filter({ hasText: name }) +``` + +**Assertions:** +```typescript +await expect(locator).toBeVisible() +await expect(locator).not.toBeVisible() +await expect(locator).toHaveAttribute("data-state", "checked") +await expect(locator).toContainText("text") +``` + +**Form inputs:** +```typescript +await input.fill("text") // Clears and types +await input.press("Enter") +await input.press("Escape") +``` + +**Radix modal workaround:** +```typescript +await button.click({ force: true }); // Force click, bypasses pointer-events checks +``` + +**Waiting for API responses:** +```typescript +const responsePromise = page.waitForResponse( + resp => resp.url().includes("/api/...") && resp.request().method() === "POST", + { timeout: 30_000 }, +); +await page.getByTestId("submit").click(); +const response = await responsePromise; +expect([200, 201]).toContain(response.status()); +``` + +**Cleanup with API helpers:** +```typescript +import { deleteGroupsByPrefix, deleteServicesByPrefix } from "../helpers/api"; + +// At the start of a test or in cleanup +await deleteServicesByPrefix(page, "my-prefix-"); +await deleteGroupsByPrefix(page, "my-prefix-"); +``` + +### Sidebar Navigation + +```typescript +await visitByNavigation(page, "Access Control"); // Expand parent +await visitByNavigation(page, "Policies"); // Click child +``` + +| Parent | Children | +|--------|----------| +| Access Control | Policies, Groups, Posture Checks | +| Team | Users, Service Users | +| DNS | Nameservers, Zones, DNS Settings | +| Reverse Proxy | Custom Domains, Services | + +## Test Coverage + +| Area | Spec Files | Tag | +|------|-----------|-----| +| Access Control | `access-control.spec.ts`, `access-control-groups.spec.ts` | `@access-control` | +| DNS | `dns-zones.spec.ts`, `dns-nameservers.spec.ts`, `dns-settings.spec.ts` | `@dns` | +| Networks | `networks.spec.ts`, `network-routes.spec.ts` | `@network` | +| Reverse Proxy | `reverse-proxy-services-https.spec.ts`, `reverse-proxy-services-tcp.spec.ts`, `reverse-proxy-services-tls.spec.ts`, `reverse-proxy-services-udp.spec.ts`, `reverse-proxy-custom-domains.spec.ts` | `@reverse-proxy` | +| Settings | `settings-authentication.spec.ts`, `settings-clients.spec.ts`, `settings-groups.spec.ts`, `settings-networks.spec.ts`, `settings-permissions.spec.ts` | `@settings` | +| Notifications | `settings-notifications-email.spec.ts`, `settings-notifications-slack.spec.ts`, `settings-notifications-webhook.spec.ts` | `@notifications` | +| Team | `team-users.spec.ts`, `team-service-users.spec.ts`, `team-users-approval-and-billing.spec.ts` | `@team` | +| Setup Keys | `setup-keys.spec.ts` | `@setup-keys` | + +## Debugging + +1. `e2e/test-results/` — traces and screenshots on failure +2. `npx playwright show-report` — open the HTML report +3. `npm run test:ui` — interactive mode with step-by-step execution +4. `npx playwright test --config=e2e/playwright.config.ts --debug tests/` — debugger mode + +## `data-testid` Conventions + +- Use `data-testid` selectors throughout. Add new ones to React components as needed. +- Kebab-case naming: `feature-field-input`, `action-feature`, `feature-actions`. +- Always use `data-testid` — both on native HTML elements and custom components. Custom components declare `"data-testid"?: string` in their props interface and place it on the appropriate internal DOM element. diff --git a/e2e/environment/.gitignore b/e2e/environment/.gitignore new file mode 100644 index 0000000..e83bd1b --- /dev/null +++ b/e2e/environment/.gitignore @@ -0,0 +1,12 @@ +# Ignore zitadel environment +.env +Caddyfile +management.json +turnserver.conf +zitadel.env +proxy.env +proxy-no-ports.env +proxy-certs/ +proxy-certs-no-ports/ +docker-compose.yml +/machinekey \ No newline at end of file diff --git a/e2e/environment/clean-test-env.sh b/e2e/environment/clean-test-env.sh new file mode 100644 index 0000000..6445e7d --- /dev/null +++ b/e2e/environment/clean-test-env.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +check_docker_compose() { + if command -v docker-compose &> /dev/null + then + echo "docker-compose" + return + fi + if docker compose --help &> /dev/null + then + echo "docker compose" + return + fi + + echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 +} + +DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + +$DOCKER_COMPOSE_COMMAND down --volumes +rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env +rm -rf proxy-certs proxy-certs-no-ports +rm -f ../../.test-config.json ../playwright.env.json +rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json \ No newline at end of file diff --git a/e2e/environment/create-test-env.sh b/e2e/environment/create-test-env.sh new file mode 100644 index 0000000..183a677 --- /dev/null +++ b/e2e/environment/create-test-env.sh @@ -0,0 +1,927 @@ +#!/bin/bash + +set -e + +# Tag of the management-cloud image to pull. Override via env var to pin the +# tests to a specific management-cloud build (e.g., a feature branch image). +MANAGEMENT_IMAGE_TAG="${MANAGEMENT_IMAGE_TAG:-main}" +echo "Using ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}" + +# Tag of the reverse-proxy image to pull. Override via env var to pin the +# tests to a specific reverse-proxy build (e.g., a feature branch image). +REVERSE_PROXY_IMAGE_TAG="${REVERSE_PROXY_IMAGE_TAG:-main}" +echo "Using ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}" + +handle_request_command_status() { + PARSED_RESPONSE=$1 + FUNCTION_NAME=$2 + RESPONSE=$3 + if [[ $PARSED_RESPONSE -ne 0 ]]; then + echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr + exit 1 + fi +} + +handle_zitadel_request_response() { + PARSED_RESPONSE=$1 + FUNCTION_NAME=$2 + RESPONSE=$3 + if [[ $PARSED_RESPONSE == "null" ]]; then + echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr + exit 1 + fi + sleep 1 +} + +check_docker_compose() { + if command -v docker-compose &> /dev/null + then + echo "docker-compose" + return + fi + if docker compose --help &> /dev/null + then + echo "docker compose" + return + fi + + echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 +} + +check_jq() { + if ! command -v jq &> /dev/null + then + echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr + exit 1 + fi +} + +wait_proxy_cluster() { + SERVICE_NAME=${1:-reverse-proxy} + echo -n "Waiting for $SERVICE_NAME to register with management " + set +e + local attempts=60 + local i + for ((i = 1; i <= attempts; i++)); do + if $DOCKER_COMPOSE_COMMAND logs "$SERVICE_NAME" 2>&1 | grep -q "Initial mapping sync complete"; then + echo " done" + set -e + return + fi + echo -n " ." + sleep 2 + done + echo "" + echo "ERROR: $SERVICE_NAME did not register with management after $((attempts * 2))s" + echo "--- $SERVICE_NAME logs ---" + $DOCKER_COMPOSE_COMMAND logs --tail=50 "$SERVICE_NAME" || true + exit 1 +} + +wait_crdb() { + set +e + while true; do + if $DOCKER_COMPOSE_COMMAND exec -T crdb curl -sf -o /dev/null 'http://localhost:8080/health?ready=1'; then + break + fi + echo -n " ." + sleep 5 + done + echo " done" + set -e +} + +init_crdb() { + echo -e "\nInitializing Zitadel's CockroachDB\n\n" + $DOCKER_COMPOSE_COMMAND up -d crdb + echo "" + # shellcheck disable=SC2028 + echo -n "Waiting cockroachDB to become ready " + wait_crdb + $DOCKER_COMPOSE_COMMAND exec -T crdb /bin/bash -c "cp /cockroach/certs/* /zitadel-certs/ && cockroach cert create-client --overwrite --certs-dir /zitadel-certs/ --ca-key /zitadel-certs/ca.key zitadel_user && chown -R 1000:1000 /zitadel-certs/" + handle_request_command_status $? "init_crdb failed" "" +} + +get_main_ip_address() { + if [[ "$OSTYPE" == "darwin"* ]]; then + interface=$(route -n get default | grep 'interface:' | awk '{print $2}') + ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}') + else + interface=$(ip route | grep default | awk '{print $5}' | head -n 1) + ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1) + fi + + echo "$ip_address" +} + +wait_pat() { + PAT_PATH=$1 + set +e + while true; do + if [[ -f "$PAT_PATH" ]]; then + break + fi + echo -n " ." + sleep 1 + done + echo " done" + set -e +} + +wait_api() { + INSTANCE_URL=$1 + PAT=$2 + set +e + while true; do + curl -s --fail -o /dev/null "$INSTANCE_URL/auth/v1/users/me" -H "Authorization: Bearer $PAT" + if [[ $? -eq 0 ]]; then + break + fi + echo -n " ." + sleep 1 + done + echo " done" + set -e +} + +create_new_project() { + INSTANCE_URL=$1 + PAT=$2 + PROJECT_NAME="NETBIRD" + + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/management/v1/projects" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{"name": "'"$PROJECT_NAME"'"}' + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.id') + handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_project" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +create_new_application() { + INSTANCE_URL=$1 + PAT=$2 + APPLICATION_NAME=$3 + BASE_REDIRECT_URL1=$4 + BASE_REDIRECT_URL2=$5 + LOGOUT_URL=$6 + ZITADEL_DEV_MODE=$7 + + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/management/v1/projects/$PROJECT_ID/apps/oidc" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "'"$APPLICATION_NAME"'", + "redirectUris": [ + "'"$BASE_REDIRECT_URL1"'", + "'"$BASE_REDIRECT_URL2"'" + ], + "postLogoutRedirectUris": [ + "'"$LOGOUT_URL"'" + ], + "RESPONSETypes": [ + "OIDC_RESPONSE_TYPE_CODE" + ], + "grantTypes": [ + "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", + "OIDC_GRANT_TYPE_REFRESH_TOKEN" + ], + "appType": "OIDC_APP_TYPE_USER_AGENT", + "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", + "version": "OIDC_VERSION_1_0", + "devMode": '"$ZITADEL_DEV_MODE"', + "accessTokenType": "OIDC_TOKEN_TYPE_JWT", + "accessTokenRoleAssertion": true, + "skipNativeAppSuccessPage": true + }' + ) + + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.clientId') + handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_application" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +create_service_user() { + INSTANCE_URL=$1 + PAT=$2 + + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/management/v1/users/machine" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{ + "userName": "netbird-service-account", + "name": "Netbird Service Account", + "description": "Netbird Service Account for IDP management", + "accessTokenType": "ACCESS_TOKEN_TYPE_JWT" + }' + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId') + handle_zitadel_request_response "$PARSED_RESPONSE" "create_service_user" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +create_service_user_secret() { + INSTANCE_URL=$1 + PAT=$2 + USER_ID=$3 + + RESPONSE=$( + curl -sS -X PUT "$INSTANCE_URL/management/v1/users/$USER_ID/secret" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{}' + ) + SERVICE_USER_CLIENT_ID=$(echo "$RESPONSE" | jq -r '.clientId') + handle_zitadel_request_response "$SERVICE_USER_CLIENT_ID" "create_service_user_secret_id" "$RESPONSE" + SERVICE_USER_CLIENT_SECRET=$(echo "$RESPONSE" | jq -r '.clientSecret') + handle_zitadel_request_response "$SERVICE_USER_CLIENT_SECRET" "create_service_user_secret" "$RESPONSE" +} + +add_organization_user_manager() { + INSTANCE_URL=$1 + PAT=$2 + USER_ID=$3 + + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/management/v1/orgs/me/members" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{ + "userId": "'"$USER_ID"'", + "roles": [ + "ORG_USER_MANAGER" + ] + }' + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate') + handle_zitadel_request_response "$PARSED_RESPONSE" "add_organization_user_manager" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +create_admin_user() { + INSTANCE_URL=$1 + PAT=$2 + USERNAME=$3 + PASSWORD=$4 + FIRST_NAME=${5:-"Zitadel"} + LAST_NAME=${6:-"Admin"} + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/management/v1/users/human/_import" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{ + "userName": "'"$USERNAME"'", + "profile": { + "firstName": "'"$FIRST_NAME"'", + "lastName": "'"$LAST_NAME"'" + }, + "email": { + "email": "'"$USERNAME"'", + "isEmailVerified": true + }, + "password": "'"$PASSWORD"'", + "passwordChangeRequired": false + }' + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId') + handle_zitadel_request_response "$PARSED_RESPONSE" "create_admin_user" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +add_instance_admin() { + INSTANCE_URL=$1 + PAT=$2 + USER_ID=$3 + + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/admin/v1/members" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{ + "userId": "'"$USER_ID"'", + "roles": [ + "IAM_OWNER" + ] + }' + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate') + handle_zitadel_request_response "$PARSED_RESPONSE" "add_instance_admin" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +delete_auto_service_user() { + INSTANCE_URL=$1 + PAT=$2 + + RESPONSE=$( + curl -sS -X GET "$INSTANCE_URL/auth/v1/users/me" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + ) + USER_ID=$(echo "$RESPONSE" | jq -r '.user.id') + handle_zitadel_request_response "$USER_ID" "delete_auto_service_user_get_user" "$RESPONSE" + + RESPONSE=$( + curl -sS -X DELETE "$INSTANCE_URL/admin/v1/members/$USER_ID" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate') + handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_instance_permissions" "$RESPONSE" + + RESPONSE=$( + curl -sS -X DELETE "$INSTANCE_URL/management/v1/orgs/me/members/$USER_ID" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate') + handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_org_permissions" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +create_proxy_token() { + TOKEN_NAME=$1 + echo "Creating proxy token '$TOKEN_NAME'..." >&2 + local attempts=30 + local delay=2 + local i + local out="" + local tok="" + for ((i = 1; i <= attempts; i++)); do + out=$($DOCKER_COMPOSE_COMMAND exec -T management /go/bin/netbird-mgmt token create \ + --name "$TOKEN_NAME" \ + --config /etc/netbird/management.json \ + --log-file console \ + --log-level error 2>&1 || true) + + tok=$(echo "$out" | grep "^Token:" | awk '{print $2}') + if [ -n "$tok" ]; then + break + fi + echo " attempt $i/$attempts: management not ready yet, retrying in ${delay}s..." >&2 + sleep "$delay" + done + + if [ -z "$tok" ]; then + echo "ERROR: Failed to create proxy token '$TOKEN_NAME' after $attempts attempts" >&2 + echo "Last output from management:" >&2 + echo "$out" >&2 + echo "--- docker compose ps ---" >&2 + $DOCKER_COMPOSE_COMMAND ps >&2 || true + echo "--- management logs ---" >&2 + $DOCKER_COMPOSE_COMMAND logs --tail=200 management >&2 || true + exit 1 + fi + echo "Proxy token '$TOKEN_NAME' created: ${tok:0:10}..." >&2 + echo "$tok" +} + +init_proxy_tokens() { + echo "Waiting for management container to become ready..." + # Default proxy (supports custom ports) + NB_PROXY_TOKEN=$(create_proxy_token "test-proxy") + cat > proxy.env < proxy-no-ports.env < /dev/stderr + return 1 + fi + + if [ "$DOMAIN" == "netbird.example.com" ]; then + echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr + return 1 + fi + return 0 +} + +read_nb_domain() { + READ_NETBIRD_DOMAIN="" + echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr + read -r READ_NETBIRD_DOMAIN < /dev/tty + if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then + read_nb_domain + fi + echo "$READ_NETBIRD_DOMAIN" +} + +initEnvironment() { + CADDY_SECURE_DOMAIN="" + ZITADEL_EXTERNALSECURE="false" + ZITADEL_TLS_MODE="disabled" + ZITADEL_MASTERKEY="$(openssl rand -base64 32 | head -c 32)" + NETBIRD_PORT=33080 + NETBIRD_HTTP_PROTOCOL="http" + TURN_USER="self" + TURN_PASSWORD=$(openssl rand -base64 32 | sed 's/=//g') + TURN_MIN_PORT=49152 + TURN_MAX_PORT=65535 + + NETBIRD_DOMAIN=$(get_main_ip_address) + + if [[ "$OSTYPE" == "darwin"* ]]; then + ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -v+30M "+%Y-%m-%dT%H:%M:%SZ") + else + ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -d "+30 minutes" "+%Y-%m-%dT%H:%M:%SZ") + fi + + check_jq + + DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + + if [ -f zitadel.env ]; then + echo "Generated files already exist, if you want to reinitialize the environment, please remove them first." + echo "You can use the following commands:" + echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes" + echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports" + echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." + exit 1 + fi + + echo Rendering initial files... + renderDockerCompose > docker-compose.yml + renderCaddyfile > Caddyfile + renderZitadelEnv > zitadel.env + echo "" > turnserver.conf + echo "" > management.json + echo "" > proxy.env + echo "" > proxy-no-ports.env + + mkdir -p machinekey + chmod 777 machinekey + + init_crdb + + echo -e "\nStarting Zidatel IDP for user management\n\n" + $DOCKER_COMPOSE_COMMAND up -d caddy zitadel + init_zitadel + + echo -e "\nRendering NetBird files...\n" + renderTurnServerConf > turnserver.conf + renderManagementJson > management.json + renderDashboardEnv > "../../.test-config.json" + + echo -e "\nRendering Playwright environment file...\n" + renderPlaywrightEnv > "../playwright.env.json" + + echo -e "\nPulling latest images...\n" + docker pull "ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}" + docker pull "ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}" + + # Pre-create the proxy cert directories BEFORE starting containers so that + # docker's bind-mounts (./proxy-certs and ./proxy-certs-no-ports) reuse our + # runner-owned dirs instead of creating root-owned ones, which would + # prevent openssl from writing the generated keys/certs below. Each proxy + # gets its own cert dir so it registers with a distinct identity (a shared + # cert collapses both proxies onto one proxy ID and management superseding + # flaps cluster registration). + mkdir -p proxy-certs proxy-certs-no-ports + + echo -e "\nStarting NetBird services\n" + $DOCKER_COMPOSE_COMMAND up -d + + echo -e "\nWaiting for management to be ready...\n" + sleep 5 + + echo -e "\nGenerating self-signed TLS certificates for reverse proxies...\n" + openssl req -x509 -newkey rsa:2048 -keyout proxy-certs/tls.key -out proxy-certs/tls.crt \ + -days 365 -nodes -subj "/CN=example.com" \ + -addext "subjectAltName=DNS:example.com,DNS:*.example.com,DNS:noports.example.com,DNS:*.noports.example.com" + chmod 644 proxy-certs/tls.key proxy-certs/tls.crt + openssl req -x509 -newkey rsa:2048 -keyout proxy-certs-no-ports/tls.key -out proxy-certs-no-ports/tls.crt \ + -days 365 -nodes -subj "/CN=noports.example.com" \ + -addext "subjectAltName=DNS:noports.example.com,DNS:*.noports.example.com" + chmod 644 proxy-certs-no-ports/tls.key proxy-certs-no-ports/tls.crt + + echo -e "\nCreating proxy access tokens...\n" + init_proxy_tokens + + echo -e "\nStarting reverse proxy services...\n" + $DOCKER_COMPOSE_COMMAND up -d reverse-proxy reverse-proxy-no-ports + + echo -e "\nWaiting for reverse proxies to register with management...\n" + wait_proxy_cluster reverse-proxy + wait_proxy_cluster reverse-proxy-no-ports + + echo -e "\nDone!\n" + echo "Run 'npm run test:dev' to start the dashboard at http://localhost:1337" + echo "Management API is at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT" + echo "Login with the following credentials:" + echo "Username: $ZITADEL_ADMIN_USERNAME" | tee .env + echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env +} + +renderCaddyfile() { + cat <(); + +async function getApiContext( + page: Page, +): Promise<{ token: string; origin: string }> { + const cached = apiContextCache.get(page); + if (cached) return cached; + + // Navigate to the users page to trigger an API call we can intercept. + // The predicate runs for EVERY response the page receives and returns + // whether it's the one we want: a successful GET to the management API. + // Non-matching responses (4xx/5xx, non-GET, non-API) are skipped — the + // wait keeps going until a match or the 10s timeout. Network-level + // request failures never produce a response, so they can't match either; + // if nothing succeeds, this throws a TimeoutError. + // Set E2E_DEBUG_API=1 to log every API response the predicate considers. + const debugApi = !!process.env.E2E_DEBUG_API; + const [response] = await Promise.all([ + page.waitForResponse( + (resp) => { + const req = resp.request(); + if (!resp.url().includes("/api/")) return false; + const isMatch = req.method() === "GET" && resp.status() === 200; + if (debugApi) { + // eslint-disable-next-line no-console + console.log( + `[api-context] ${req.method()} ${resp.status()} ${resp.url()} ${ + isMatch ? "← MATCH" : "(skipped)" + }`, + ); + } + return isMatch; + }, + { timeout: 10_000 }, + ), + page.goto("/team/users"), + ]); + + const request = response.request(); + const authHeader = + (await request.allHeaders())["authorization"] || ""; + const token = authHeader.replace("Bearer ", ""); + const url = new URL(request.url()); + const origin = `${url.protocol}//${url.host}`; + + if (!token) { + throw new Error("Could not capture auth token from API response"); + } + + const ctx = { token, origin }; + apiContextCache.set(page, ctx); + return ctx; +} + +async function apiGet(page: Page, path: string): Promise { + const { token, origin } = await getApiContext(page); + const resp = await page.request.get(`${origin}/api${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return resp.json(); +} + +async function apiDelete(page: Page, path: string): Promise { + const { token, origin } = await getApiContext(page); + await page.request.delete(`${origin}/api${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +/** List all groups. */ +export async function listGroups(page: Page): Promise { + return apiGet(page, "/groups"); +} + +/** Delete a group by ID. */ +export async function deleteGroup(page: Page, groupId: string) { + await apiDelete(page, `/groups/${groupId}`); +} + +/** Delete all groups matching a prefix. */ +export async function deleteGroupsByPrefix(page: Page, prefix: string) { + const groups = await listGroups(page); + const toDelete = groups.filter((g) => g.name.startsWith(prefix)); + for (const g of toDelete) { + await deleteGroup(page, g.id); + } +} + +// ── Networks ──────────────────────────────────────────────────────────── + +type Network = { + id: string; + name: string; +}; + +/** List all networks. */ +export async function listNetworks(page: Page): Promise { + return apiGet(page, "/networks"); +} + +/** Delete a network by ID. */ +export async function deleteNetworkById(page: Page, networkId: string) { + await apiDelete(page, `/networks/${networkId}`); +} + +/** Delete all networks matching a prefix. */ +export async function deleteNetworksByPrefix(page: Page, prefix: string) { + const networks = await listNetworks(page); + const toDelete = networks.filter((n) => n.name.startsWith(prefix)); + for (const n of toDelete) { + await deleteNetworkById(page, n.id); + } +} + +// ── Policies ─────────────────────────────────────────────────────────── + +type Policy = { + id: string; + name: string; + description: string; + rules: { sources: string[]; destinations: string[] }[]; +}; + +/** List all policies. */ +export async function listPolicies(page: Page): Promise { + return apiGet(page, "/policies"); +} + +/** Delete a policy by ID. */ +export async function deletePolicyById(page: Page, policyId: string) { + await apiDelete(page, `/policies/${policyId}`); +} + +/** Delete all policies whose name or description contains a substring. */ +export async function deletePoliciesBySubstring(page: Page, substring: string) { + const policies = await listPolicies(page); + const toDelete = policies.filter( + (p) => p.name?.includes(substring) || p.description?.includes(substring), + ); + for (const p of toDelete) { + await deletePolicyById(page, p.id); + } +} + +/** Delete all policies that reference a group name in sources or destinations. */ +export async function deletePoliciesByGroupName(page: Page, groupName: string) { + const [policies, groups] = await Promise.all([ + listPolicies(page), + listGroups(page), + ]); + const groupId = groups.find((g) => g.name === groupName)?.id; + if (!groupId) return; + + const toDelete = policies.filter((p) => + p.rules.some( + (r) => r.sources?.includes(groupId) || r.destinations?.includes(groupId), + ), + ); + for (const p of toDelete) { + await deletePolicyById(page, p.id); + } +} + +// ── Routes ───────────────────────────────────────────────────────────── + +type Route = { + id: string; + network_id: string; +}; + +/** List all routes. */ +export async function listRoutes(page: Page): Promise { + return apiGet(page, "/routes"); +} + +/** Delete a route by ID. */ +export async function deleteRouteById(page: Page, routeId: string) { + await apiDelete(page, `/routes/${routeId}`); +} + +/** Delete all routes matching a network_id prefix. */ +export async function deleteRoutesByNetworkIdPrefix(page: Page, prefix: string) { + const routes = await listRoutes(page); + const toDelete = routes.filter((r) => r.network_id.startsWith(prefix)); + for (const r of toDelete) { + await deleteRouteById(page, r.id); + } +} + +// ── Setup Keys ───────────────────────────────────────────────────────── + +type SetupKey = { + id: string; + name: string; +}; + +/** List all setup keys. */ +export async function listSetupKeys(page: Page): Promise { + return apiGet(page, "/setup-keys"); +} + +/** Delete a setup key by ID. */ +export async function deleteSetupKeyById(page: Page, keyId: string) { + await apiDelete(page, `/setup-keys/${keyId}`); +} + +/** Delete all setup keys matching a name prefix. */ +export async function deleteSetupKeysByPrefix(page: Page, prefix: string) { + const keys = await listSetupKeys(page); + const toDelete = keys.filter((k) => k.name.startsWith(prefix)); + for (const k of toDelete) { + await deleteSetupKeyById(page, k.id); + } +} + +// ── DNS Zones ────────────────────────────────────────────────────────── + +type DnsZone = { + id: string; + domain: string; +}; + +/** List all DNS zones. */ +export async function listDnsZones(page: Page): Promise { + return apiGet(page, "/dns/zones"); +} + +/** Delete a DNS zone by ID. */ +export async function deleteDnsZoneById(page: Page, zoneId: string) { + await apiDelete(page, `/dns/zones/${zoneId}`); +} + +/** Delete all DNS zones matching a domain prefix. */ +export async function deleteDnsZonesByPrefix(page: Page, prefix: string) { + const zones = await listDnsZones(page); + const toDelete = zones.filter((z) => z.domain.startsWith(prefix)); + for (const z of toDelete) { + await deleteDnsZoneById(page, z.id); + } +} + +// ── Notification Channels ───────────────────────────────────────────── + +type NotificationChannel = { + id: string; + type: string; + enabled: boolean; +}; + +/** List all notification channels. */ +export async function listNotificationChannels(page: Page): Promise { + return apiGet(page, "/integrations/notifications/channels"); +} + +/** Delete a notification channel by ID. */ +export async function deleteNotificationChannel(page: Page, channelId: string) { + await apiDelete(page, `/integrations/notifications/channels/${channelId}`); +} + +/** Delete all notification channels. */ +export async function deleteAllNotificationChannels(page: Page) { + const channels = await listNotificationChannels(page); + for (const c of channels) { + await deleteNotificationChannel(page, c.id); + } +} + +/** Delete notification channels by type (e.g., "email", "slack", "webhook"). */ +export async function deleteNotificationChannelsByType(page: Page, type: string) { + const channels = await listNotificationChannels(page); + const toDelete = channels.filter((c) => c.type === type); + for (const c of toDelete) { + await deleteNotificationChannel(page, c.id); + } +} + +// ── Nameservers ─────────────────────────────────────────────────────── + +type NameserverGroup = { + id: string; + name: string; +}; + +/** List all nameserver groups. */ +export async function listNameserverGroups(page: Page): Promise { + return apiGet(page, "/dns/nameservers"); +} + +/** Delete a nameserver group by ID. */ +export async function deleteNameserverGroupById(page: Page, id: string) { + await apiDelete(page, `/dns/nameservers/${id}`); +} + +/** Delete all nameserver groups matching a name prefix. */ +export async function deleteNameserverGroupsByPrefix(page: Page, prefix: string) { + const groups = await listNameserverGroups(page); + const toDelete = groups.filter((g) => g.name.startsWith(prefix)); + for (const g of toDelete) { + await deleteNameserverGroupById(page, g.id); + } +} + +// ── Reverse Proxy Services ──────────────────────────────────────────── + +type ReverseProxyService = { + id: string; + name: string; +}; + +/** List all reverse proxy services. */ +export async function listReverseProxyServices(page: Page): Promise { + return apiGet(page, "/reverse-proxies/services"); +} + +/** Delete a reverse proxy service by ID. */ +export async function deleteReverseProxyServiceById(page: Page, serviceId: string) { + await apiDelete(page, `/reverse-proxies/services/${serviceId}`); +} + +/** Delete all reverse proxy services matching a name prefix. */ +export async function deleteServicesByPrefix(page: Page, prefix: string) { + const services = await listReverseProxyServices(page); + const toDelete = services.filter((s) => s.name.startsWith(prefix)); + for (const s of toDelete) { + await deleteReverseProxyServiceById(page, s.id); + } +} + +// ── Reverse Proxy Clusters ──────────────────────────────────────────── + +type ReverseProxyCluster = { + id?: string; + address: string; + online: boolean; + connected_proxies: number; +}; + +/** List all reverse proxy clusters. */ +export async function listReverseProxyClusters( + page: Page, +): Promise { + return apiGet(page, "/reverse-proxies/clusters"); +} + +/** + * Poll the management API until every given cluster address is present and + * online with at least one connected proxy. The test reverse-proxy + * containers register asynchronously after `test:setup` returns, so the + * domain picker can be briefly empty; gating here keeps the reverse-proxy + * suite deterministic instead of flaking on a half-registered env. + */ +export async function waitForProxyClustersOnline( + page: Page, + addresses: string[], + timeoutMs = 120_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: ReverseProxyCluster[] = []; + while (Date.now() < deadline) { + // Don't silently coerce errors to "no clusters" — a failed call (token + // capture timeout, 401, network) is a different problem than an empty + // list, and hiding it makes the gate undiagnosable. + last = await listReverseProxyClusters(page).catch((err) => { + // eslint-disable-next-line no-console + console.warn( + `[clusters-gate] list call failed: ${(err as Error).message}`, + ); + return []; + }); + const ready = addresses.every((addr) => + last.some( + (c) => c.address === addr && c.online && c.connected_proxies > 0, + ), + ); + if (ready) return; + await page.waitForTimeout(3000); + } + throw new Error( + `Proxy clusters not online after ${timeoutMs}ms. Expected ${addresses.join( + ", ", + )}; got ${JSON.stringify(last.map((c) => ({ a: c.address, online: c.online, n: c.connected_proxies })))}`, + ); +} + +// ── Users ───────────────────────────────────────────────────────────── + +type User = { + id: string; + email: string; + name: string; + role: string; + status: string; + is_current: boolean; +}; + +/** List all users. */ +export async function listUsers(page: Page): Promise { + return apiGet(page, "/users"); +} + +/** Delete a user by ID. */ +export async function deleteUserById(page: Page, userId: string) { + await apiDelete(page, `/users/${userId}`); +} + +/** Delete a user by email (skip current user). */ +export async function deleteUserByEmail(page: Page, email: string) { + const users = await listUsers(page); + const user = users.find((u) => u.email === email && !u.is_current); + if (user) { + await deleteUserById(page, user.id); + } +} diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..2e7e82c --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,117 @@ +/** + * Login helper for Playwright tests. + * + * The OIDC library (@axa-fr/react-oidc) uses a service worker for token + * management, so storageState alone can't restore a session. Each test + * goes through the OIDC redirect flow. Zitadel session cookies from + * storageState make re-auth fast (account selection, no credentials). + */ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { clearScrollLock } from "./utils"; + +export type TestUser = "owner" | "user"; + +const credentials: Record = { + owner: { username: "owner@localhost.test", password: "testMe123@" }, + user: { username: "user@localhost.test", password: "testMe123@" }, +}; + +/** + * Navigate to the app, authenticate via Zitadel, and wait for the app to load. + */ +export async function loginToApp(page: Page, user: TestUser = "owner") { + const { username, password } = credentials[user]; + + await page.goto("/"); + + // The app either loads directly or redirects to Zitadel. + // Use locators that match either outcome — Playwright auto-waits. + const appReady = page.getByTestId("left-navigation-item").first(); + const setupModal = page.getByTestId("setup-netbird-modal"); + const approvalPending = page.getByText("User Approval Pending"); + const onboarding = page.getByText("Add new device to your network"); + const selectAccount = page.getByText("Select account"); + const loginInput = page.locator("input[id=loginName]"); + const passwordInput = page.locator("input[id=password]"); + + // Wait for any of these outcomes + const which = await Promise.race([ + appReady.waitFor({ timeout: 20_000 }).then(() => "app" as const), + setupModal.waitFor({ timeout: 20_000 }).then(() => "modal" as const), + approvalPending.waitFor({ timeout: 20_000 }).then(() => "approval" as const), + onboarding.waitFor({ timeout: 20_000 }).then(() => "onboarding" as const), + selectAccount.waitFor({ timeout: 20_000 }).then(() => "select" as const), + loginInput.waitFor({ timeout: 20_000 }).then(() => "login" as const), + passwordInput.waitFor({ timeout: 20_000 }).then(() => "password" as const), + ]); + + if (which === "app") { + return; + } + + if (which === "modal") { + await setupModal.getByTestId("modal-close").click(); + await expect(setupModal).not.toBeVisible(); + return; + } + + if (which === "approval" || which === "onboarding") { + return; + } + + // We're on Zitadel + if (which === "select") { + await page.getByText(username).click(); + } else if (which === "login") { + await loginInput.fill(username); + await page.locator("button[id=submit-button]").click(); + await passwordInput.waitFor({ state: "visible" }); + await passwordInput.fill(password); + await page.locator("button[id=submit-button]").click(); + } else { + // password form directly + await passwordInput.fill(password); + await page.locator("button[id=submit-button]").click(); + } + + // Handle 2FA skip if shown + const skipButton = page.locator("button[name=skip]"); + if (await skipButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await skipButton.click(); + } + + // Wait for either nav or modal to appear + await Promise.race([ + appReady.waitFor({ timeout: 15_000 }), + setupModal.waitFor({ timeout: 15_000 }), + approvalPending.waitFor({ timeout: 15_000 }), + onboarding.waitFor({ timeout: 15_000 }), + ]); + + // Dismiss setup modal if present + if (await setupModal.isVisible().catch(() => false)) { + await setupModal.getByTestId("modal-close").click(); + await expect(setupModal).not.toBeVisible(); + } + + // Clear any stale Radix overlays + await clearScrollLock(page); +} + +/** + * Navigate to a path within the app, dismissing the setup modal if it appears. + * Use this instead of page.goto() for in-app navigation after loginToApp(). + */ +export async function navigateTo(page: Page, path: string) { + await page.goto(path, { waitUntil: "domcontentloaded" }); + const modal = page.getByTestId("setup-netbird-modal"); + try { + await modal.waitFor({ state: "visible", timeout: 3_000 }); + await modal.getByTestId("modal-close").click(); + await expect(modal).not.toBeVisible(); + } catch { + // No modal — fine + } + await clearScrollLock(page); +} diff --git a/e2e/helpers/fixtures.ts b/e2e/helpers/fixtures.ts new file mode 100644 index 0000000..a58e6d8 --- /dev/null +++ b/e2e/helpers/fixtures.ts @@ -0,0 +1,49 @@ +/** + * Custom Playwright fixtures that provide pre-authenticated pages. + * + * Usage: + * import { test, expect } from "../helpers/fixtures"; + * test.describe.serial("My Feature", () => { + * test("first test", async ({ dashboardAsOwner }) => { ... }); + * }); + * + * `dashboardAsOwner` logs in once (via OIDC redirect) and reuses the same + * browser page for every test in the worker — no per-test login overhead. + */ +import { test as base, type Page, type BrowserContext } from "@playwright/test"; +import { loginToApp, type TestUser } from "./auth"; + +type Fixtures = { + dashboardAsOwner: Page; + dashboardAsUser: Page; +}; + +export const test = base.extend<{}, Fixtures>({ + dashboardAsOwner: [ + async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/owner.json", + }); + const page = await context.newPage(); + await loginToApp(page, "owner"); + await use(page); + await context.close(); + }, + { scope: "worker" }, + ], + + dashboardAsUser: [ + async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/user.json", + }); + const page = await context.newPage(); + await loginToApp(page, "user"); + await use(page); + await context.close(); + }, + { scope: "worker" }, + ], +}); + +export { expect } from "@playwright/test"; diff --git a/e2e/helpers/navigation.ts b/e2e/helpers/navigation.ts new file mode 100644 index 0000000..2468de3 --- /dev/null +++ b/e2e/helpers/navigation.ts @@ -0,0 +1,8 @@ +import type { Page } from "@playwright/test"; + +export async function visitByNavigation(page: Page, navText: string) { + await page + .getByTestId("left-navigation-item") + .getByText(navText, { exact: true }) + .click(); +} diff --git a/e2e/helpers/reverse-proxy-l4.ts b/e2e/helpers/reverse-proxy-l4.ts new file mode 100644 index 0000000..41e919b --- /dev/null +++ b/e2e/helpers/reverse-proxy-l4.ts @@ -0,0 +1,232 @@ +/** + * Shared helpers for L4 reverse proxy tests (TLS, TCP, UDP). + * Keeps the individual spec files DRY. + */ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { navigateTo } from "./auth"; +import { generateRandomName, waitForApiCalls } from "./utils"; + +/** Create a network and return its name. */ +export async function createNetwork(page: Page): Promise { + // Networks now lives under the collapsible "Network Routing" sidebar + // group, so navigate by URL instead of clicking the (hidden) child item. + await navigateTo(page, "/networks"); + const name = generateRandomName("rp-network-"); + + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + + await page + .getByTestId("confirmation.cancel") + .click({ force: true }); + + // force: true because Radix dialog leaves data-scroll-locked on body + const searchInput = page.getByTestId("table-search-input"); + await searchInput.fill(name, { force: true }); + await expect(page.locator("tr").filter({ hasText: name })).toBeVisible(); + + return name; +} + +/** Add a resource to an already-visible network row. */ +export async function addResource( + page: Page, + networkName: string, + address: string, +): Promise { + const name = generateRandomName("rp-resource-"); + + const searchInput = page.getByTestId("table-search-input"); + await searchInput.fill(networkName, { force: true }); + await page + .locator("tr") + .filter({ hasText: networkName }) + .getByTestId("add-resource") + .click({ force: true }); + + await page.getByTestId("resource-name-input").fill(name); + await page.getByTestId("resource-address-input").fill(address); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page + .getByTestId("confirmation.confirm") + .click({ force: true }); + const response = await responsePromise; + expect([200, 201]).toContain(response.status()); + + await page + .getByTestId("confirmation.cancel") + .click({ force: true }); + + return name; +} + +/** Domains advertised by the test reverse-proxy clusters. */ +export const CUSTOM_PORTS_DOMAIN = "example.com"; +export const NO_CUSTOM_PORTS_DOMAIN = "noports.example.com"; + +/** Pick a base domain (cluster) in the service modal — deterministic when multiple clusters exist. */ +export async function selectProxyDomain(page: Page, domain: string) { + const trigger = page.getByTestId("proxy-domain-selector"); + await trigger.click({ force: true }); + // Find the option whose label span contains the exact "." text, + // so ".example.com" doesn't also match ".noports.example.com". + const option = page + .locator('[role="option"]') + .filter({ has: page.getByText(`.${domain}`, { exact: true }) }) + .first(); + await option.click({ force: true }); + // Wait for the trigger to reflect the new selection and the popover + // options to detach, so subsequent clicks aren't intercepted by Radix's + // outside-click handling during the close animation. + await expect(trigger.getByText(`.${domain}`, { exact: true })).toBeVisible(); + await option.waitFor({ state: "detached", timeout: 5_000 }).catch(() => {}); +} + +/** Select a resource target in the L4 target selector. */ +export async function selectL4Resource(page: Page, resourceName: string) { + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("group-selector-dropdown").click(); + await page + .locator('[role="tab"]') + .filter({ hasText: "Resources" }) + .click({ force: true }); + const search = page.getByTestId("group-selector-dropdown-search"); + await expect(search).toBeVisible({ timeout: 5_000 }); + await search.fill(resourceName); + await page + .locator('[role="option"], [role="listbox"] >> text=' + resourceName) + .or(page.getByText(resourceName)) + .first() + .click({ force: true, timeout: 15_000 }); +} + +/** Add the standard two access control rules (Allow Germany + Block IP). */ +export async function addAccessControlRules(page: Page) { + // Rule 1: Allow Country (Germany) + await page.getByTestId("add-access-rule").click(); + await page.getByTestId("access-rule-0").getByText("Select country...").click(); + await page + .getByTestId("select-dropdown-search") + .fill("Germany"); + await page.getByText("Germany (DE)").click({ force: true }); + + // Rule 2: Block IP Address + await page.getByTestId("add-access-rule").click(); + await page + .getByTestId("access-rule-1") + .getByTestId("access-rule-action") + .click(); + await page.getByText("Block Only").click({ force: true }); + await page + .getByTestId("access-rule-1") + .getByTestId("access-rule-type") + .click(); + await page.locator('[role="option"]').filter({ hasText: "IP Address" }).click({ force: true }); + const ipInput = page.getByTestId("access-rule-1").getByTestId("access-rule-value"); + await expect(ipInput).toBeVisible(); + await ipInput.fill("85.203.15.42"); +} + +/** Remove all access control rules (expects exactly 2). */ +export async function removeAllAccessControlRules(page: Page) { + await expect(page.getByTestId("remove-access-rule")).toHaveCount(2); + await page.getByTestId("remove-access-rule").last().click({ force: true }); + await page.getByTestId("remove-access-rule").first().click({ force: true }); +} + +/** Reset any stale filters/search so all services are visible in the table. */ +export async function resetServiceFilters(page: Page) { + const resetBtn = page.getByTestId("reset-filters-and-search"); + if (await resetBtn.isVisible().catch(() => false)) { + await resetBtn.click(); + } +} + +/** + * Navigate to a reverse-proxy page and wait for every /api/reverse-prox* + * backend call triggered by the navigation to finish before proceeding, + * so the table/picker is fully populated when the test interacts with it. + */ +export async function gotoReverseProxyPage( + page: Page, + path = "/reverse-proxy/services", +) { + await waitForApiCalls(page, () => navigateTo(page, path)); +} + +/** Open the edit modal for a service row. */ +export async function openServiceEdit(page: Page, subdomain: string) { + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + await resetServiceFilters(page); + await page + .locator("tr") + .filter({ hasText: subdomain }) + .getByTestId("service-actions") + .click({ force: true }); + await page.getByTestId("edit-service").click({ force: true }); + // Wait for the edit modal to fully load + await expect(page.getByTestId("proxy-save")).toBeVisible({ timeout: 10_000 }); +} + +/** Delete a service via the action dropdown and confirm. */ +export async function deleteService(page: Page, subdomain: string) { + await page + .locator("tr") + .filter({ hasText: subdomain }) + .getByTestId("service-actions") + .click({ force: true }); + await page.getByTestId("delete-service").click({ force: true }); + await page + .getByTestId("confirmation.confirm") + .click({ force: true }); + + await expect( + page.locator("tr").filter({ hasText: subdomain }), + ).not.toBeVisible({ timeout: 15_000 }); +} + +/** Save an edited service (handles the "No Protection" confirmation). */ +export async function saveServiceEdit(page: Page) { + await page.getByTestId("proxy-save").click(); + await page + .getByTestId("confirmation.confirm") + .click({ force: true }); +} + +/** Navigate to Networks, find the network by name, and delete it. */ +export async function deleteNetwork(page: Page, networkName: string) { + await navigateTo(page, "/networks"); + const searchInput = page.getByTestId("table-search-input"); + await expect(searchInput).toBeVisible({ timeout: 30_000 }); + await searchInput.fill(networkName, { force: true }); + await expect( + page.locator("tr").filter({ hasText: networkName }), + ).toBeVisible(); + + // Open the row's action menu (last button) and click Delete + await page + .locator("tr") + .filter({ hasText: networkName }) + .locator("button") + .last() + .click({ force: true }); + await page.getByText("Delete").click({ force: true }); + await page + .getByTestId("confirmation.confirm") + .click({ force: true }); + + await expect( + page.locator("tr").filter({ hasText: networkName }), + ).not.toBeVisible(); +} diff --git a/e2e/helpers/utils.ts b/e2e/helpers/utils.ts new file mode 100644 index 0000000..15d3296 --- /dev/null +++ b/e2e/helpers/utils.ts @@ -0,0 +1,103 @@ +import type { Page, Request } from "@playwright/test"; + +export function generateRandomName(prefix?: string): string { + return (prefix || "") + Math.random().toString(36).substring(7); +} + +/** + * Run an action (click, goto, ...) and wait until every API request whose + * URL contains `pattern` has finished (response received or failed), plus a + * short quiet window to catch request chains where one response triggers + * the next fetch. + * + * Use this to make navigation deterministic: e.g. when opening the services + * page, the table only renders fully after /api/reverse-proxies/* calls + * return, so asserting on rows right after the click races the backend. + * + * Returns whatever the action returns. + */ +export async function waitForApiCalls( + page: Page, + action: () => Promise, + { + pattern = "/api/reverse-prox", + quietMs = 500, + timeoutMs = 15_000, + }: { pattern?: string; quietMs?: number; timeoutMs?: number } = {}, +): Promise { + let inFlight = 0; + let sawRequest = false; + let lastActivity = Date.now(); + + const matches = (req: Request) => req.url().includes(pattern); + const onRequest = (req: Request) => { + if (!matches(req)) return; + inFlight++; + sawRequest = true; + lastActivity = Date.now(); + }; + const onSettled = (req: Request) => { + if (!matches(req)) return; + inFlight = Math.max(0, inFlight - 1); + lastActivity = Date.now(); + }; + + page.on("request", onRequest); + page.on("requestfinished", onSettled); + page.on("requestfailed", onSettled); + + try { + const result = await action(); + const deadline = Date.now() + timeoutMs; + // Wait until: at least one matching request was seen (unless none ever + // fires), none are in flight, and the network has been quiet for quietMs. + while (Date.now() < deadline) { + const quietFor = Date.now() - lastActivity; + if (inFlight === 0 && quietFor >= quietMs) { + if (sawRequest || quietFor >= quietMs * 2) break; + } + await page.waitForTimeout(100); + } + return result; + } finally { + page.off("request", onRequest); + page.off("requestfinished", onSettled); + page.off("requestfailed", onSettled); + } +} + +/** + * Apply a single-choice (radio) table filter via the new TableFilters UI: + * open the "Filters" popover, pick the filter by column id, then select the + * option by its visible label (e.g. "Active", "Inactive", "All"). + */ +export async function applyRadioTableFilter( + page: Page, + filterId: string, + optionLabel: string, +) { + await page.getByTestId("table-filters-button").click(); + await page.getByTestId(`table-filter-${filterId}`).click(); + const optionId = `radio-option-${optionLabel + .replace(/\s+/g, "-") + .toLowerCase()}`; + await page.getByTestId(optionId).click(); +} + +/** + * Clear stale Radix scroll-lock and overlay from body. + * Some Radix modals leave `data-scroll-locked`, `pointer-events: none`, + * or a stale overlay div blocking the entire page. + */ +export async function clearScrollLock(page: Page) { + await page.evaluate(() => { + document.body.removeAttribute("data-scroll-locked"); + document.body.style.removeProperty("pointer-events"); + // Remove stale Radix dialog overlays that block pointer events + document + .querySelectorAll( + 'div[data-state="open"].fixed[class*="backdrop-blur"]', + ) + .forEach((el) => el.remove()); + }); +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..0e7d93d --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,54 @@ +import { defineConfig, devices } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; + +const envPath = path.resolve(__dirname, "playwright.env.json"); +const env = fs.existsSync(envPath) + ? JSON.parse(fs.readFileSync(envPath, "utf-8")) + : {}; + +export default defineConfig({ + outputDir: "./test-results", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 1, + workers: process.env.CI ? 2 : 4, + reporter: process.env.CI + ? [ + ["github"], + ["html", { outputFolder: "./playwright-report", open: "never" }], + ["json", { outputFile: "test-results/results.json" }], + ] + : [ + ["list"], + ["html", { outputFolder: "./playwright-report", open: "on-failure" }], + ], + use: { + ...devices["Desktop Chrome"], + baseURL: env.BASE_URL || "http://localhost:1337", + viewport: { width: 1920, height: 1080 }, + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure", + actionTimeout: 10_000, + navigationTimeout: 15_000, + }, + testDir: "./tests", + webServer: { + command: "npx serve@latest out -p 1337 --no-request-logging", + port: 1337, + reuseExistingServer: true, + cwd: path.resolve(__dirname, ".."), + }, + projects: [ + { + name: "login", + testMatch: "login.spec.ts", + }, + { + name: "e2e", + testIgnore: "login.spec.ts", + dependencies: ["login"], + }, + ], +}); diff --git a/e2e/tests/access-control-groups.spec.ts b/e2e/tests/access-control-groups.spec.ts new file mode 100644 index 0000000..b260b09 --- /dev/null +++ b/e2e/tests/access-control-groups.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let createdGroupName = ""; + +const ALL_GROUP_TABS = [ + "policies", + "resources", + "network-routes", + "nameservers", + "zones", +]; + +const REGULAR_GROUP_TABS = [ + "users", + "peers", + ...ALL_GROUP_TABS, + "setup-keys", +]; + +test.describe.serial("Groups @access-control", () => { + // ── List page tests (no navigation between these) ────────────────── + + test('Should show the "All" group in the list', async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/groups"); + await expect( + page.locator('[aria-label="View details of group All"]'), + ).toBeVisible(); + }); + + test('Should search for "All" group and still find it', async ({ dashboardAsOwner: page }) => { + const input = page.getByTestId("table-search-input"); + await input.fill("All"); + await expect( + page.locator('[aria-label="View details of group All"]'), + ).toBeVisible(); + await input.fill(""); + }); + + test("Should create a new group", async ({ dashboardAsOwner: page }) => { + const name = generateRandomName("test-group-"); + createdGroupName = name; + await page.getByTestId("open-create-group").click(); + await page.getByTestId("group-name-input").fill(name); + await page.getByTestId("create-group").click(); + await expect(page.getByText(name).first()).toBeVisible(); + }); + + test("Should rename the group", async ({ dashboardAsOwner: page }) => { + // Go back to list via breadcrumb (client-side nav, faster than navigateTo) + await page.getByText("Groups").first().click(); + const input = page.getByTestId("table-search-input"); + await expect(input).toBeVisible(); + await input.fill(createdGroupName); + await page + .locator("tr") + .filter({ hasText: createdGroupName }) + .getByTestId("group-actions") + .click(); + await page.getByTestId("rename-group").click(); + + const newName = generateRandomName("renamed-group-"); + await page.getByTestId("group-name-input").fill(newName); + await page.getByTestId("save-group-name").click(); + await expect(page.getByText(newName).first()).toBeVisible(); + createdGroupName = newName; + }); + + // ── Detail page tests ────────────────────────────────────────────── + + test('Should open "All" group page and show only All-group tabs', async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/groups"); + const input = page.getByTestId("table-search-input"); + await input.fill(""); + await page.locator('[aria-label="View details of group All"]').click({ force: true }); + + for (const tab of ALL_GROUP_TABS) { + await expect(page.getByTestId(`group-tab-${tab}`)).toBeVisible(); + } + for (const tab of ["users", "peers", "setup-keys"]) { + await expect(page.getByTestId(`group-tab-${tab}`)).not.toBeVisible(); + } + for (const tab of ALL_GROUP_TABS) { + await page.getByTestId(`group-tab-${tab}`).click({ force: true }); + await expect(page.getByTestId(`group-tab-${tab}`)).toHaveAttribute("data-state", "active"); + } + }); + + test("Should open the new group page and show all 8 tabs", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/groups"); + const input = page.getByTestId("table-search-input"); + await expect(input).toBeVisible(); + await input.fill(createdGroupName); + await page.locator(`[aria-label="View details of group ${createdGroupName}"]`).click({ force: true }); + + for (const tab of REGULAR_GROUP_TABS) { + await expect(page.getByTestId(`group-tab-${tab}`)).toBeVisible(); + } + for (const tab of REGULAR_GROUP_TABS) { + await page.getByTestId(`group-tab-${tab}`).click({ force: true }); + await expect(page.getByTestId(`group-tab-${tab}`)).toHaveAttribute("data-state", "active"); + } + }); + + // ── Cleanup ──────────────────────────────────────────────────────── + + test("Should delete the created group", async ({ dashboardAsOwner: page }) => { + await deleteGroupsByPrefix(page, createdGroupName); + }); +}); diff --git a/e2e/tests/access-control.spec.ts b/e2e/tests/access-control.spec.ts new file mode 100644 index 0000000..c843e17 --- /dev/null +++ b/e2e/tests/access-control.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from "../helpers/fixtures"; +import { generateRandomName } from "../helpers/utils"; +import { navigateTo } from "../helpers/auth"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let policies: string[] = []; +let createdGroups: string[] = []; + +test.describe.serial("Access Controls @access-control", () => { + test("Should have default policy", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/access-control"); + await expect(page.getByText("Default", { exact: true })).toBeVisible(); + await expect(page.getByText("This is a default rule")).toBeVisible(); + }); + + test("Should create new policy", async ({ dashboardAsOwner: page }) => { + const srcGroup = generateRandomName("ac-src-"); + const dstGroup = generateRandomName("ac-dst-"); + createdGroups.push(srcGroup, dstGroup); + + const name = generateRandomName("Policy "); + await createPolicy(page, { + name, + source_groups: [srcGroup], + destination_groups: [dstGroup], + protocol: "TCP", + ports: ["80", "443"], + direction: "in", + description: "This is a test policy", + }); + policies.push(name); + }); + + test("Should delete created policies", async ({ dashboardAsOwner: page }) => { + for (const policy of policies) { + await deletePolicy(page, policy); + } + policies = []; + }); + + test("Should delete created groups", async ({ dashboardAsOwner: page }) => { + for (const prefix of createdGroups) { + await deleteGroupsByPrefix(page, prefix); + } + createdGroups = []; + }); +}); + +async function createPolicy( + page: import("@playwright/test").Page, + opts: { + protocol?: "ALL" | "TCP" | "UDP" | "ICMP"; + source_groups: string[]; + destination_groups: string[]; + direction?: "bi" | "in"; + ports?: string[]; + name: string; + description?: string; + }, +) { + await page.getByTestId("open-add-policy").click(); + + if (opts.protocol !== "ALL") { + await page.getByTestId("protocol-select-button").click(); + await page + .getByTestId("protocol-selection") + .getByText(opts.protocol!) + .click(); + } + + if (opts.direction === "in") { + await page.getByTestId("policy-direction").click(); + } + + // Add source groups + if (opts.source_groups.length > 0) { + await page.getByTestId("source-group-selector").click(); + for (const group of opts.source_groups) { + const search = page.getByTestId("source-group-selector-search"); + await expect(search).toBeVisible(); + await search.fill(group); + await search.press("Enter"); + } + await page.getByTestId("source-group-selector-search").press("Escape"); + await expect( + page.getByTestId("source-group-selector-search"), + ).not.toBeVisible(); + } + + // Add destination groups + if (opts.destination_groups.length > 0) { + await page.getByTestId("destination-group-selector").click(); + for (const group of opts.destination_groups) { + const search = page.getByTestId("destination-group-selector-search"); + await expect(search).toBeVisible(); + await search.fill(group); + await search.press("Enter"); + } + await page + .getByTestId("destination-group-selector-search") + .press("Escape"); + await expect( + page.getByTestId("destination-group-selector-search"), + ).not.toBeVisible(); + } + + // Add ports + if ( + opts.ports && + (opts.protocol === "TCP" || opts.protocol === "UDP") + ) { + await page.getByTestId("port-selector").click(); + for (const port of opts.ports) { + const input = page.getByTestId("port-input"); + await expect(input).toBeVisible(); + await input.fill(port); + await input.press("Enter"); + } + await page.getByTestId("port-input").press("Escape"); + } + + // Click Continue (policy → posture checks) + await page.getByTestId("policy-continue").click(); + // Skip posture checks and continue (posture checks → general) + await page.getByTestId("policy-continue").click(); + + // Enter name + await page.getByTestId("policy-name").fill(opts.name); + if (opts.description) { + await page.getByTestId("policy-description").fill(opts.description); + } + + // Create policy + await page.getByTestId("submit-policy").click(); + await expect(page.getByTestId(opts.name)).toBeVisible(); +} + +async function deletePolicy( + page: import("@playwright/test").Page, + name: string, +) { + // Row actions are now behind a dropdown menu. + await page + .locator("tr") + .filter({ hasText: name }) + .getByTestId("policy-actions") + .click({ force: true }); + await page.getByTestId("delete-policy").click({ force: true }); + await page.getByTestId("confirmation.confirm").click(); + await expect(page.getByTestId(name)).not.toBeVisible({ timeout: 10_000 }); +} diff --git a/e2e/tests/dns-nameservers.spec.ts b/e2e/tests/dns-nameservers.spec.ts new file mode 100644 index 0000000..f953eba --- /dev/null +++ b/e2e/tests/dns-nameservers.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix, deleteNameserverGroupsByPrefix } from "../helpers/api"; + +let nsName = ""; +let nsDomain = ""; +let nsGroup1 = ""; +let nsGroup2 = ""; + +test.describe.serial("DNS - Nameservers @dns", () => { + test("Should show all 4 DNS presets and create a custom nameserver", async ({ + dashboardAsOwner: page, + }) => { + // Clean up stale nameservers and groups from previous runs + await deleteNameserverGroupsByPrefix(page, "test-ns-"); + await deleteNameserverGroupsByPrefix(page, "renamed-ns-"); + await deleteGroupsByPrefix(page, "ns-group-"); + await deleteGroupsByPrefix(page, "ns-domain-"); + + await navigateTo(page, "/dns/nameservers"); + + await page.getByTestId("open-add-nameserver").click(); + await expect(page.getByTestId("nameserver-preset-google")).toBeVisible(); + await expect(page.getByTestId("nameserver-preset-cloudflare")).toBeVisible(); + await expect(page.getByTestId("nameserver-preset-quad9")).toBeVisible(); + await expect(page.getByTestId("nameserver-preset-custom")).toBeVisible(); + + // Create via Custom DNS + await page.getByTestId("nameserver-preset-custom").click(); + + await page.getByTestId("nameserver-ip-input").first().fill("10.0.0.1"); + await page.getByTestId("add-nameserver-row").click(); + await page.getByTestId("nameserver-ip-input").last().fill("10.0.0.2"); + await page.getByTestId("nameserver-port-input").last().fill("5353"); + + const groupName = generateRandomName("ns-group-"); + nsGroup1 = groupName; + await page.getByTestId("nameserver-groups-selector").click(); + await page.getByTestId("nameserver-groups-selector-search").fill(groupName); + await page.getByTestId("nameserver-groups-selector-search").press("Enter"); + await page.getByTestId("nameserver-groups-selector-search").press("Escape"); + + await page.getByTestId("nameserver-continue").click(); + + // Domains tab + const d = generateRandomName("ns-domain-"); + nsDomain = `${d}.internal`; + await page.getByTestId("add-match-domain").click(); + await page.getByTestId("domain-input").last().fill(nsDomain); + await page.getByTestId("nameserver-mark-search-domains").click(); + + await page.getByTestId("nameserver-continue").click(); + + // General tab + const name = generateRandomName("test-ns-"); + nsName = name; + await page.getByTestId("nameserver-name-input").fill(name); + await page.getByTestId("nameserver-description-input").fill("Test nameserver"); + + await page.getByTestId("submit-nameserver").click(); + }); + + test("Should verify the nameserver in the table", async ({ dashboardAsOwner: page }) => { + const row = page.locator("tr").filter({ hasText: nsName }); + await expect(row).toBeVisible({ timeout: 10_000 }); + await expect(row.getByText(nsDomain)).toBeVisible(); + await expect(row.getByText("10.0.0.1")).toBeVisible(); + await expect(row.getByText("10.0.0.2")).toBeVisible(); + await expect(row.getByText(nsGroup1)).toBeVisible(); + // Active state moved into the row action menu: a freshly-created + // nameserver is enabled, so the toggle item reads "Disable". + await row.getByTestId("nameserver-actions").click({ force: true }); + await expect(page.getByTestId("nameserver-active-toggle")).toContainText( + "Disable", + ); + await page.keyboard.press("Escape"); + }); + + test("Should edit the nameserver", async ({ dashboardAsOwner: page }) => { + await page.locator("tr").filter({ hasText: nsName }).getByTestId("nameserver-name-cell").click({ force: true }); + + // Nameserver tab — change IPs and add group + await page.getByTestId("nameserver-tab-nameserver").click({ force: true }); + await expect(page.getByTestId("nameserver-ip-input").first()).toBeVisible(); + await page.getByTestId("nameserver-ip-input").first().fill("192.168.1.1"); + await page.getByTestId("nameserver-ip-input").last().fill("192.168.1.2"); + + const groupName = generateRandomName("ns-group-"); + nsGroup2 = groupName; + await page.getByTestId("nameserver-groups-selector").click(); + await page.getByTestId("nameserver-groups-selector-search").fill(groupName); + await page.getByTestId("nameserver-groups-selector-search").press("Enter"); + await page.getByTestId("nameserver-groups-selector-search").press("Escape"); + + // Domains tab — remove domain + await page.getByTestId("nameserver-tab-domains").click({ force: true }); + await page.getByTestId("domain-input-remove").click({ force: true }); + + // General tab — rename + await page.getByTestId("nameserver-tab-general").click({ force: true }); + const newName = generateRandomName("renamed-ns-"); + await page.getByTestId("nameserver-name-input").fill(newName); + await page.getByTestId("nameserver-description-input").fill("Updated"); + + await page.getByTestId("submit-nameserver").click(); + await expect(page.getByText("successfully").first()).toBeVisible({ timeout: 10_000 }); + // Verify the renamed nameserver appears in the table + await expect(page.locator("tr").filter({ hasText: newName })).toBeVisible({ timeout: 10_000 }); + nsName = newName; + }); + + test("Should verify edits and toggle active state", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/dns/nameservers"); + const row = page.locator("tr").filter({ hasText: nsName }); + await expect(row).toBeVisible({ timeout: 10_000 }); + await expect(row.getByText("192.168.1.1")).toBeVisible(); + await expect(row.getByText("192.168.1.2")).toBeVisible(); + // Distribution-groups cell now renders a count badge (2 groups after edit). + await expect(row.getByText("2 Groups")).toBeVisible(); + + // Toggle active off and back on via the row action menu. + // Two races to defend against on each toggle: + // 1. Radix leaves `pointer-events: none` on body briefly during the + // close transition — re-opening without `force: true` makes + // Playwright auto-wait for the body to accept pointer events. + // 2. The toast fires before SWR refetches `/dns/nameservers`, so the + // row's `ns.enabled` is stale and the re-opened menu shows the + // old label. Wait for the GET refetch before re-opening. + const actions = row.getByTestId("nameserver-actions"); + const toggle = page.getByTestId("nameserver-active-toggle"); + const waitForRefetch = () => + page.waitForResponse( + (r) => + r.url().includes("/api/dns/nameservers") && + r.request().method() === "GET" && + r.ok(), + { timeout: 10_000 }, + ); + + await actions.click({ force: true }); + let refetch = waitForRefetch(); + await toggle.click({ force: true }); + await expect(page.getByText("successfully disabled").first()).toBeVisible(); + await refetch; + + await expect(toggle).toBeHidden(); + await actions.click(); + await expect(toggle).toContainText("Enable"); + refetch = waitForRefetch(); + await toggle.click({ force: true }); + await expect(page.getByText("successfully enabled").first()).toBeVisible(); + await refetch; + await expect(toggle).toBeHidden(); + }); + + test("Should delete the nameserver and groups", async ({ dashboardAsOwner: page }) => { + await page.locator("tr").filter({ hasText: nsName }).getByTestId("nameserver-actions").click({ force: true }); + await page.getByTestId("delete-nameserver").click({ force: true }); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await expect(page.locator("tr").filter({ hasText: nsName })).not.toBeVisible(); + + for (const group of [nsGroup1, nsGroup2]) { + if (!group) continue; + await deleteGroupsByPrefix(page, group); + } + }); +}); diff --git a/e2e/tests/dns-settings.spec.ts b/e2e/tests/dns-settings.spec.ts new file mode 100644 index 0000000..e371835 --- /dev/null +++ b/e2e/tests/dns-settings.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let dnsGroups: string[] = []; + +test.describe.serial("DNS - Settings @dns", () => { + test("Should add groups to DNS disabled management", async ({ dashboardAsOwner: page }) => { + // Clean up stale groups from previous failed runs + await deleteGroupsByPrefix(page, "dns-group-"); + + await navigateTo(page, "/dns/settings"); + + const name1 = generateRandomName("dns-group-"); + const name2 = generateRandomName("dns-group-"); + dnsGroups = [name1, name2]; + + await expect(page.getByTestId("dns-groups-selector")).toBeVisible({ timeout: 15_000 }); + + // Remove any existing group badges before adding new ones + const existingBadges = page.getByTestId("group-badge"); + const badgeCount = await existingBadges.count(); + for (let i = 0; i < badgeCount; i++) { + await existingBadges.first().click(); + } + if (badgeCount > 0) { + await page.getByTestId("save-changes").click(); + await expect(page.getByText("successfully").first()).toBeVisible(); + } + + for (const group of dnsGroups) { + // Ensure dropdown is closed before reopening + const search = page.getByTestId("dns-groups-selector-search"); + if (await search.isVisible().catch(() => false)) { + await page.keyboard.press("Escape"); + await expect(search).not.toBeVisible({ timeout: 3_000 }); + } + await page.getByTestId("dns-groups-selector-open-close").click({ force: true }); + await expect(search).toBeVisible({ timeout: 5_000 }); + await search.fill(group); + await search.press("Enter"); + // Wait for the group badge to appear before continuing + await expect(page.getByText(group).first()).toBeVisible({ timeout: 5_000 }); + } + // Close the dropdown if still open + await page.keyboard.press("Escape"); + + for (const group of dnsGroups) { + await expect(page.getByText(group).first()).toBeVisible(); + } + + const saveResponse = page.waitForResponse( + (resp) => resp.url().includes("/api/dns/settings") && resp.request().method() === "PUT", + { timeout: 10_000 }, + ); + await page.getByTestId("save-changes").click(); + await saveResponse; + await expect(page.getByText("successfully").first()).toBeVisible(); + }); + + test("Should persist groups after reload and then remove them", async ({ + dashboardAsOwner: page, + }) => { + await page.reload(); + await expect(page.getByTestId("dns-groups-selector")).toBeVisible({ timeout: 15_000 }); + for (const group of dnsGroups) { + await expect(page.getByText(group).first()).toBeVisible({ timeout: 10_000 }); + } + + // Remove groups + for (const group of dnsGroups) { + await page.getByTestId("group-badge").filter({ hasText: group }).click(); + } + await page.getByTestId("save-changes").click(); + + // Verify removed after reload + await page.reload(); + for (const group of dnsGroups) { + await expect(page.getByTestId("group-badge").filter({ hasText: group })).toHaveCount(0); + } + }); + + test("Should delete the created groups", async ({ dashboardAsOwner: page }) => { + for (const group of dnsGroups) { + await deleteGroupsByPrefix(page, group); + } + }); +}); diff --git a/e2e/tests/dns-zones.spec.ts b/e2e/tests/dns-zones.spec.ts new file mode 100644 index 0000000..66cfb0a --- /dev/null +++ b/e2e/tests/dns-zones.spec.ts @@ -0,0 +1,197 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { applyRadioTableFilter, generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix, deleteDnsZonesByPrefix } from "../helpers/api"; + +let zoneDomain = ""; +let zoneGroup = ""; +let zoneGroup2 = ""; + +test.describe.serial("DNS - Zones @dns", () => { + test("Should add a new zone with a distribution group", async ({ dashboardAsOwner: page }) => { + // Clean up leftover zones from previous runs + await deleteDnsZonesByPrefix(page, "dns-zone-"); + await deleteGroupsByPrefix(page, "zone-group-"); + + await navigateTo(page, "/dns/zones"); + + const name = generateRandomName("dns-zone-"); + zoneDomain = `${name}.test`; + + await page.getByTestId("add-dns-zone").click(); + await page.getByTestId("dns-zone-domain-input").fill(zoneDomain); + + const groupName = generateRandomName("zone-group-"); + zoneGroup = groupName; + await page.getByTestId("dns-zone-groups-selector").click(); + await page.getByTestId("dns-zone-groups-selector-search").fill(groupName); + await page.getByTestId("dns-zone-groups-selector-search").press("Enter"); + await page.getByTestId("dns-zone-groups-selector-search").press("Escape"); + + await page.getByTestId("dns-zone-search-domains").click(); + await expect(page.getByTestId("dns-zone-enabled")).toHaveAttribute("data-state", "checked"); + + await page.getByTestId("submit-dns-zone").click(); + await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible(); + }); + + test("Should add A, AAAA, and CNAME records", async ({ dashboardAsOwner: page }) => { + const zoneRow = page.locator("tr").filter({ hasText: zoneDomain }); + + // Dismiss or use the "Add Record" prompt from zone creation + const addRecordBtn = page.getByTestId("confirmation.confirm"); + if (await addRecordBtn.isVisible().catch(() => false)) { + await addRecordBtn.click({ force: true }); + } else { + await zoneRow.getByTestId("add-dns-record").click({ force: true }); + } + await expect(page.getByTestId("dns-record-hostname-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("dns-record-hostname-input").fill("server1"); + await page.getByTestId("dns-record-content-input").fill("10.0.0.10"); + await page.getByTestId("dns-record-ttl-select").click(); + await page.locator('[role="option"]').filter({ hasText: "1 Min." }).click({ force: true }); + await page.getByTestId("submit-dns-record").click(); + await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("1"); + + // AAAA record + await zoneRow.getByTestId("add-dns-record").click({ force: true }); + await page.getByTestId("dns-record-type-select").click(); + await page.locator('[role="option"]').filter({ hasText: "AAAA" }).click({ force: true }); + await page.getByTestId("dns-record-hostname-input").fill("server2"); + await page.getByTestId("dns-record-content-input").fill("2001:db8::1"); + await page.getByTestId("submit-dns-record").click(); + await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("2"); + + // CNAME record + await zoneRow.getByTestId("add-dns-record").click({ force: true }); + await page.getByTestId("dns-record-type-select").click(); + await page.locator('[role="option"]').filter({ hasText: "CNAME" }).click({ force: true }); + await page.getByTestId("dns-record-hostname-input").fill("alias"); + await page.getByTestId("dns-record-content-input").fill("server1.example.com"); + await page.getByTestId("submit-dns-record").click(); + await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("3"); + }); + + test("Should edit a record", async ({ dashboardAsOwner: page }) => { + await page.reload(); + // Expand accordion to show records + await page.locator("tr").filter({ hasText: zoneDomain }).first().click({ force: true }); + await expect(page.getByText("10.0.0.10")).toBeVisible({ timeout: 10_000 }); + + // Edit A record + await page.getByTestId("edit-dns-record").first().click({ force: true }); + await page.getByTestId("dns-record-hostname-input").fill("web1"); + await page.getByTestId("dns-record-content-input").fill("10.0.0.99"); + await page.getByTestId("submit-dns-record").click(); + await expect(page.getByText(`web1.${zoneDomain}`).first()).toBeVisible(); + }); + + test("Should toggle active and search domain states", async ({ dashboardAsOwner: page }) => { + await page.reload(); + const row = page.locator("tr").filter({ hasText: zoneDomain }); + + // Active state moved into the row action menu (Enable/Disable item). + // Zone starts enabled → item reads "Disable"; toggle off then on, + // reopening the menu each time to read the updated label. + // Two races to defend against on each toggle: + // 1. Radix leaves `pointer-events: none` on body briefly during the + // close transition — re-opening without `force: true` makes + // Playwright auto-wait for the body to accept pointer events. + // 2. The toggle's PUT resolves before SWR refetches `/dns/zones`, so + // the row's `zone.enabled` is stale and the re-opened menu shows + // the old label. Wait for the GET refetch before re-opening. + const actions = row.getByTestId("dns-zone-actions"); + const toggle = page.getByTestId("dns-zone-active-toggle"); + const waitForRefetch = () => + page.waitForResponse( + (r) => + r.url().includes("/api/dns/zones") && + r.request().method() === "GET" && + r.ok(), + { timeout: 10_000 }, + ); + + await actions.click({ force: true }); + let refetch = waitForRefetch(); + await toggle.click({ force: true }); + await refetch; + + await expect(toggle).toBeHidden(); + await actions.click(); + await expect(toggle).toContainText("Enable"); + refetch = waitForRefetch(); + await toggle.click({ force: true }); + await refetch; + + await expect(toggle).toBeHidden(); + await actions.click(); + await expect(toggle).toContainText("Disable"); + await page.keyboard.press("Escape"); + + // Toggle search domain off + const searchToggle = row.getByTestId("dns-zone-search-domain-toggle"); + await searchToggle.click({ force: true }); + await expect(searchToggle).toHaveAttribute("data-state", "unchecked"); + }); + + test("Should update distribution groups", async ({ dashboardAsOwner: page }) => { + const newGroup = generateRandomName("zone-group-"); + zoneGroup2 = newGroup; + + await page.locator("tr").filter({ hasText: zoneDomain }).getByTestId("multiple-groups").click({ force: true }); + await expect(page.getByTestId("save-groups")).toBeVisible(); + + await page.getByTestId("group-selector-dropdown").click(); + await page.getByTestId("group-selector-dropdown-search").fill(newGroup); + await page.getByTestId("group-selector-dropdown-search").press("Enter"); + await page.getByTestId("group-selector-dropdown-search").press("Escape"); + + await page.getByTestId("save-groups").click(); + await expect(page.getByTestId("save-groups")).not.toBeVisible(); + }); + + test("Should edit the zone and toggle settings back", async ({ dashboardAsOwner: page }) => { + // Page is on /dns/zones from previous test + await page.locator("tr").filter({ hasText: zoneDomain }).getByTestId("dns-zone-actions").click({ force: true }); + await page.getByTestId("edit-dns-zone").click({ force: true }); + + await page.getByTestId("dns-zone-search-domains").click(); + await expect(page.getByTestId("dns-zone-search-domains")).toHaveAttribute("data-state", "checked"); + + await page.getByTestId("submit-dns-zone").click(); + }); + + test("Should filter and search zones", async ({ dashboardAsOwner: page }) => { + await page.reload(); + const zoneRow = page.locator("tr").filter({ hasText: zoneDomain }).first(); + + // Filter: Active should show, Inactive should hide + await applyRadioTableFilter(page, "enabled", "Active"); + await expect(zoneRow).toBeVisible(); + await applyRadioTableFilter(page, "enabled", "Inactive"); + await expect(zoneRow).toBeHidden(); + await applyRadioTableFilter(page, "enabled", "All"); + + // Search by domain + const searchInput = page.getByTestId("table-search-input"); + await searchInput.fill(zoneDomain); + await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible(); + + // Search by content + await searchInput.fill("10.0.0.99"); + await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible(); + + // Search by group + await searchInput.fill(zoneGroup); + await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible(); + await searchInput.fill(""); + }); + + test("Should delete the zone and groups", async ({ dashboardAsOwner: page }) => { + await deleteDnsZonesByPrefix(page, zoneDomain); + for (const group of [zoneGroup, zoneGroup2]) { + if (!group) continue; + await deleteGroupsByPrefix(page, group); + } + }); +}); diff --git a/e2e/tests/edition-gating.spec.ts b/e2e/tests/edition-gating.spec.ts new file mode 100644 index 0000000..f5a0bb8 --- /dev/null +++ b/e2e/tests/edition-gating.spec.ts @@ -0,0 +1,216 @@ +/** + * Temporary spec validating edition gating (cloud / licensed / oss). + * + * The test build hard-codes APP_ENV=test, so isNetBirdCloud() normally returns + * true. This spec uses the test-only `netbird-test-edition` localStorage + * override (see testEditionOverride in src/utils/netbird.ts) to drive each + * edition against the OSS test management backend, which does not report the + * premium permission modules (edr, idp, event_streaming). That absence is what + * triggered the original `permission.event_streaming.read` crash and is now + * covered by withDefaultModules in PermissionsProvider. + */ +import { test, expect, type Browser, type Page } from "@playwright/test"; +import { loginToApp, navigateTo } from "../helpers/auth"; + +type Edition = "cloud" | "licensed" | "oss"; + +// Premium permission modules the open-source management server does not report. +const PREMIUM_MODULES = [ + "edr", + "idp", + "event_streaming", + "assistant", + "msp", + "tenants", + "billing", + "proxy", + "proxy_configuration", +]; + +// stripPremiumModules rewrites /users/current to drop the premium permission +// modules, reproducing an open-source management backend regardless of what the +// test management returns. This is the exact condition that crashed before the +// withDefaultModules default in PermissionsProvider. +async function stripPremiumModules(page: Page) { + await page.route("**/users/current", async (route) => { + const response = await route.fetch(); + let body: any; + try { + body = await response.json(); + } catch (e) { + return route.fulfill({ response }); + } + if (body?.permissions?.modules) { + PREMIUM_MODULES.forEach((m) => delete body.permissions.modules[m]); + } + return route.fulfill({ response, json: body }); + }); +} + +async function openAs( + browser: Browser, + edition: Edition, + opts: { stripModules?: boolean } = {}, +): Promise<{ page: Page; close: () => Promise }> { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/owner.json", + }); + await context.addInitScript((ed) => { + try { + window.localStorage.setItem("netbird-test-edition", ed as string); + } catch (e) {} + }, edition); + const page = await context.newPage(); + if (opts.stripModules) await stripPremiumModules(page); + await loginToApp(page, "owner"); + return { page, close: () => context.close() }; +} + +function collectPageErrors(page: Page): string[] { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + return errors; +} + +const SELF_HOSTED_CTA = "self-hosted-upgrade-cta"; +const START_TRIAL = "Start 14-Day Free Trial"; + +test.describe.serial("Edition gating @edition", () => { + test("integrations renders when premium permission modules are absent", async ({ + browser, + }) => { + // Reproduces the original crash: OSS management omits event_streaming/edr/ + // idp permission modules, and the integrations children read them directly. + const { page, close } = await openAs(browser, "oss", { + stripModules: true, + }); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/integrations"); + + await expect( + page.getByText("Identity Provider Sync").first(), + ).toBeVisible(); + await expect(page.getByText("MDM & EDR").first()).toBeVisible(); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); + + test("integrations renders without crashing on oss (teaser + upsell)", async ({ + browser, + }) => { + const { page, close } = await openAs(browser, "oss"); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/integrations"); + + // Tabs render (the crash happened while rendering these children). + await expect( + page.getByText("Identity Provider Sync").first(), + ).toBeVisible(); + await expect(page.getByText("MDM & EDR").first()).toBeVisible(); + + // Self-hosted upsell CTA is present. + await expect(page.getByTestId(SELF_HOSTED_CTA).first()).toBeVisible(); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); + + test("integrations renders unlocked on licensed (no upsell)", async ({ + browser, + }) => { + const { page, close } = await openAs(browser, "licensed"); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/integrations"); + + await expect( + page.getByText("Identity Provider Sync").first(), + ).toBeVisible(); + + // Licensed self-hosted unlocks features: no upsell CTA. + await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); + + test("traffic events is locked with cloud upgrade CTA on cloud free", async ({ + browser, + }) => { + const { page, close } = await openAs(browser, "cloud"); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/events/traffic"); + + // Cloud free plan locks the feature with a trial/upgrade CTA, not the + // self-hosted license CTA. + await expect(page.getByText(START_TRIAL).first()).toBeVisible(); + await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); + + test("traffic events is locked with self-hosted CTA on oss", async ({ + browser, + }) => { + const { page, close } = await openAs(browser, "oss"); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/events/traffic"); + + await expect(page.getByTestId(SELF_HOSTED_CTA).first()).toBeVisible(); + await expect(page.getByText(START_TRIAL)).toHaveCount(0); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); + + test("traffic events is unlocked on licensed (no upsell)", async ({ + browser, + }) => { + const { page, close } = await openAs(browser, "licensed"); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/events/traffic"); + + await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0); + await expect(page.getByText(START_TRIAL)).toHaveCount(0); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); +}); diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts new file mode 100644 index 0000000..5615e57 --- /dev/null +++ b/e2e/tests/login.spec.ts @@ -0,0 +1,109 @@ +import { test } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; +import { waitForProxyClustersOnline } from "../helpers/api"; +import { loginToApp } from "../helpers/auth"; + +type TestUser = "owner" | "user"; + +const AUTH_DIR = path.resolve(__dirname, "../fixtures/auth"); + +const credentials: Record = { + owner: { username: "owner@localhost.test", password: "testMe123@" }, + user: { username: "user@localhost.test", password: "testMe123@" }, +}; + +async function loginAndSave( + page: import("@playwright/test").Page, + user: TestUser, +) { + const { username, password } = credentials[user]; + + await page.goto("/"); + + await page.locator("input[id=loginName]").waitFor({ state: "visible" }); + await page.locator("input[id=loginName]").fill(username); + await page.locator("button[id=submit-button]").click(); + await page.locator("input[id=password]").waitFor({ state: "visible" }); + await page.locator("input[id=password]").fill(password); + await page.locator("button[id=submit-button]").click(); + + // After submitting credentials, we land on either: + // - 2FA skip prompt, or + // - the app directly (redirect to localhost:1337) + const skipButton = page.locator("button[name=skip]"); + const appNav = page.getByTestId("left-navigation-item").first(); + const modal = page.getByTestId("setup-netbird-modal"); + const approval = page.getByText("User Approval Pending"); + + const after_login = await Promise.race([ + skipButton.waitFor({ timeout: 15_000 }).then(() => "2fa" as const), + appNav.waitFor({ timeout: 15_000 }).then(() => "app" as const), + modal.waitFor({ timeout: 15_000 }).then(() => "modal" as const), + approval.waitFor({ timeout: 15_000 }).then(() => "approval" as const), + ]); + + if (after_login === "2fa") { + await skipButton.click(); + await Promise.race([ + appNav.waitFor({ timeout: 15_000 }), + modal.waitFor({ timeout: 15_000 }), + approval.waitFor({ timeout: 15_000 }), + ]); + } + + // Dismiss setup modal if present + if (await modal.isVisible().catch(() => false)) { + await modal.getByTestId("modal-close").click(); + } + + await page + .context() + .storageState({ path: path.join(AUTH_DIR, `${user}.json`) }); +} + +test.describe("Global Setup", () => { + for (const user of ["owner", "user"] as TestUser[]) { + test(`authenticate ${user}`, async ({ page }) => { + const authFile = path.join(AUTH_DIR, `${user}.json`); + test.skip(fs.existsSync(authFile), `${user} auth file already exists`); + await loginAndSave(page, user); + }); + } + + // Wait for the test reverse-proxy clusters to be registered and online + // before the rest of the suite runs. They come up asynchronously after + // test:setup, so without this the reverse-proxy specs flake when the + // domain picker is still empty. + // + // This deliberately does NOT fail the run if the clusters never appear: + // it only adds a bounded wait so slow registration is absorbed. A hard + // gate would skip the entire suite on any cluster hiccup, which is worse + // than letting the individual reverse-proxy specs report the problem. + test("wait for reverse-proxy clusters to be online", async ({ browser }) => { + test.setTimeout(15_000); + const context = await browser.newContext({ + storageState: path.join(AUTH_DIR, "owner.json"), + }); + const page = await context.newPage(); + try { + // storageState only carries the Zitadel session cookies — the app + // still needs the OIDC redirect flow to get an access token before + // it makes any API call, so log in like every other consumer does. + await loginToApp(page, "owner"); + await waitForProxyClustersOnline(page, [ + "example.com", + "noports.example.com", + ]); + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + `[setup] proxy clusters not confirmed online; reverse-proxy specs may be affected: ${ + (err as Error).message + }`, + ); + } finally { + await context.close(); + } + }); +}); diff --git a/e2e/tests/network-routes.spec.ts b/e2e/tests/network-routes.spec.ts new file mode 100644 index 0000000..c35df20 --- /dev/null +++ b/e2e/tests/network-routes.spec.ts @@ -0,0 +1,176 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix, deleteRoutesByNetworkIdPrefix } from "../helpers/api"; + +const networkRoutes: string[] = []; +let networkRoutesCreatedGroups: string[] = []; + +async function closePopover( + page: import("@playwright/test").Page, + selectorCy: string, +) { + await page.getByTestId(`${selectorCy}-search`).press("Escape"); + await expect(page.getByTestId(`${selectorCy}-search`)).not.toBeVisible(); +} + +test.describe.serial("Network Routes @network", () => { + test("Should create a network route with IP range", async ({ dashboardAsOwner: page }) => { + // Clean up leftovers from previous runs + await deleteRoutesByNetworkIdPrefix(page, "network-route-"); + await deleteGroupsByPrefix(page, "route-peer-"); + await deleteGroupsByPrefix(page, "route-dist-"); + await deleteGroupsByPrefix(page, "route-acl-"); + await navigateTo(page, "/network-routes"); + + const peerGroup = generateRandomName("route-peer-"); + const distGroup = generateRandomName("route-dist-"); + const aclGroup = generateRandomName("route-acl-"); + networkRoutesCreatedGroups.push(peerGroup, distGroup, aclGroup); + + const name = generateRandomName("network-route-"); + await createNetworkRoute(page, { + name, + range: "192.168.1.0/24", + peer_groups: [peerGroup], + distribution_groups: [distGroup], + access_control_groups: [aclGroup], + description: "This is a test route", + }); + networkRoutes.push(name); + }); + + test("Should create a network route with domains", async ({ dashboardAsOwner: page }) => { + const peerGroup = generateRandomName("route-peer-"); + const distGroup = generateRandomName("route-dist-"); + const aclGroup = generateRandomName("route-acl-"); + networkRoutesCreatedGroups.push(peerGroup, distGroup, aclGroup); + + const name = generateRandomName("network-route-"); + await createNetworkRoute(page, { + name, + domains: ["netbird.io"], + peer_groups: [peerGroup], + distribution_groups: [distGroup], + access_control_groups: [aclGroup], + description: "This is a test route with domains", + }); + networkRoutes.push(name); + }); + + test("Should delete network routes", async ({ dashboardAsOwner: page }) => { + for (const route of networkRoutes) { + await deleteNetworkRoute(page, route); + } + }); + + test("Should delete created groups", async ({ dashboardAsOwner: page }) => { + for (const prefix of networkRoutesCreatedGroups) { + await deleteGroupsByPrefix(page, prefix); + } + networkRoutesCreatedGroups = []; + }); +}); + +async function createNetworkRoute( + page: import("@playwright/test").Page, + opts: { + range?: string; + domains?: string[]; + peer_groups?: string[]; + distribution_groups?: string[]; + access_control_groups?: string[]; + name: string; + description?: string; + masquerade?: boolean; + metric?: string; + }, +) { + await page.getByTestId("open-add-route").click(); + + if (opts.range) { + await page.getByTestId("network-range").fill(opts.range); + } + + if (opts.domains && opts.domains.length > 0) { + await page.getByTestId("route-type-domains").click(); + for (const domain of opts.domains) { + await page.getByTestId("add-domain").click(); + await page.getByTestId("domain-input").last().fill(domain); + } + } + + if (opts.peer_groups && opts.peer_groups.length > 0) { + await page.getByTestId("route-tab-peer-group").click(); + await page.getByTestId("routing-peer-groups-selector").click(); + for (const group of opts.peer_groups) { + const search = page.getByTestId("routing-peer-groups-selector-search"); + await expect(search).toBeVisible({ timeout: 10_000 }); + await search.fill(group); + await search.press("Enter"); + } + await closePopover(page, "routing-peer-groups-selector"); + } + + await page.getByTestId("route-continue").click(); + + if (opts.distribution_groups && opts.distribution_groups.length > 0) { + await page.getByTestId("distribution-groups-selector").click(); + for (const group of opts.distribution_groups) { + const search = page.getByTestId("distribution-groups-selector-search"); + await expect(search).toBeVisible(); + await search.fill(group); + await search.press("Enter"); + } + await closePopover(page, "distribution-groups-selector"); + } + + if (opts.access_control_groups && opts.access_control_groups.length > 0) { + await page.getByTestId("access-control-groups-selector").click(); + for (const group of opts.access_control_groups) { + const search = page.getByTestId("access-control-groups-selector-search"); + await expect(search).toBeVisible(); + await search.fill(group); + await search.press("Enter"); + } + await closePopover(page, "access-control-groups-selector"); + } + + await page.getByTestId("route-continue").click(); + + await page.getByTestId("network-identifier").fill(opts.name); + if (opts.description) { + await page.getByTestId("description").fill(opts.description); + } + + await page.getByTestId("route-continue").click(); + + if (opts.masquerade === false) { + await page.getByText("Masquerade").click(); + } + + if (opts.metric) { + await page.getByTestId("metric").fill(opts.metric); + } + + await page.getByTestId("submit-route").click(); + + if (opts.access_control_groups && opts.access_control_groups.length > 0) { + await page.getByTestId("confirmation.cancel").click(); + } + + await expect(page.getByTestId(opts.name)).toBeVisible(); +} + +async function deleteNetworkRoute( + page: import("@playwright/test").Page, + name: string, +) { + await page + .locator("tr") + .filter({ hasText: name }) + .getByRole("button", { name: "Delete" }) + .click(); + await page.getByTestId("confirmation.confirm").click(); + await expect(page.getByTestId(name)).not.toBeVisible(); +} diff --git a/e2e/tests/networks.spec.ts b/e2e/tests/networks.spec.ts new file mode 100644 index 0000000..43ced75 --- /dev/null +++ b/e2e/tests/networks.spec.ts @@ -0,0 +1,164 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix, deleteNetworksByPrefix, deletePoliciesByGroupName, deletePoliciesBySubstring } from "../helpers/api"; + +let networkName = ""; +let resourceName = ""; +let policySourceGroup = ""; +let routingPeerGroup = ""; + +test.describe.serial("Networks @network", () => { + test("Should create a network with a resource, policy, and routing peer", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/networks"); + + const name = generateRandomName("test-network-"); + networkName = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("network-description-input").fill("E2E test network"); + await page.getByTestId("submit-network").click(); + + // "Add Resource?" → confirm + await page.getByTestId("confirmation.confirm").click({ force: true }); + + // Resource tab + const resName = generateRandomName("test-resource-"); + resourceName = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.50.0.1"); + await page.getByTestId("resource-optional-settings").click(); + await page.getByTestId("resource-description-input").fill("E2E test resource"); + await page.getByTestId("resource-continue").click(); + + // Access control tab — add policy + await page.getByTestId("add-policy").click(); + const srcGroup = generateRandomName("net-src-group-"); + policySourceGroup = srcGroup; + await page.getByTestId("source-group-selector").click(); + await page.getByTestId("source-group-selector-search").fill(srcGroup); + await page.getByTestId("source-group-selector-search").press("Enter"); + await page.getByTestId("source-group-selector-search").press("Escape"); + await page.getByTestId("policy-continue").click(); + await page.getByTestId("policy-continue").click(); + await page.getByTestId("submit-policy").click(); + + // Submit resource + await page.getByTestId("submit-resource").click(); + + // "Add Routing Peer?" → confirm + await page.getByTestId("confirmation.confirm").click({ force: true }); + + // Routing peer + await page.getByTestId("routing-peer-tab-group").click({ force: true }); + const rpGroup = generateRandomName("net-rp-group-"); + routingPeerGroup = rpGroup; + await page.getByTestId("group-selector-dropdown").click(); + await page.getByTestId("group-selector-dropdown-search").fill(rpGroup); + await page.getByTestId("group-selector-dropdown-search").press("Enter"); + await page.getByTestId("group-selector-dropdown-search").press("Escape"); + await page.getByTestId("routing-peer-continue").click(); + await page.getByTestId("toggle-masquerade").click(); + await page.getByTestId("metric").fill("100"); + await page.getByTestId("submit-routing-peer").click(); + + // Verify network in table + await expect(page.locator("tr").filter({ hasText: name })).toBeVisible({ timeout: 10_000 }); + }); + + test("Should add a CIDR range resource", async ({ dashboardAsOwner: page }) => { + await addResourceToNetwork(page, "cidr-resource-", "192.168.100.0/24"); + }); + + test("Should add a domain resource", async ({ dashboardAsOwner: page }) => { + await addResourceToNetwork(page, "domain-resource-", "resource.internal"); + }); + + test("Should rename the network from the table", async ({ dashboardAsOwner: page }) => { + // Page is already on /networks from previous test + const row = page.locator("tr").filter({ hasText: networkName }); + await expect(row).toBeVisible(); + + await row.getByTestId("network-actions").click({ force: true }); + await page.getByTestId("rename-network").click({ force: true }); + + const newName = generateRandomName("test-network-"); + await page.getByTestId("network-name-input").fill(newName); + await page.getByTestId("network-description-input").fill("Updated description"); + await page.getByTestId("submit-network").click(); + + await expect(page.locator("tr").filter({ hasText: newName })).toBeVisible({ timeout: 10_000 }); + networkName = newName; + }); + + test("Should navigate to the network detail page and verify tabs", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/networks"); + const row = page.locator("tr").filter({ hasText: networkName }); + await expect(row).toBeVisible({ timeout: 10_000 }); + await row.locator("button").first().click(); + + // Wait for detail page to load (tab bar appears) + await expect(page.locator('[role="tab"]').filter({ hasText: "Resource" })).toBeVisible(); + await expect(page.getByText(resourceName).first()).toBeVisible({ timeout: 10_000 }); + + // Routing Peers tab + await page.getByTestId("network-tab-routing-peers").click(); + await expect(page.getByText(routingPeerGroup).first()).toBeVisible(); + + // Services tab + await page.getByTestId("network-tab-services").click(); + await expect(page.getByTestId("network-tab-services")).toHaveAttribute("data-state", "active"); + }); + + test("Should rename the network from the detail page", async ({ dashboardAsOwner: page }) => { + // Already on the detail page from previous test + await page.getByTestId("network-detail-actions").click(); + await page.getByTestId("rename-network").click({ force: true }); + + const newName = generateRandomName("test-network-"); + await page.getByTestId("network-name-input").fill(newName); + await page.getByTestId("network-description-input").fill("Renamed from detail page"); + await page.getByTestId("submit-network").click(); + + await expect(page.getByText(newName).first()).toBeVisible(); + networkName = newName; + }); + + test("Should delete the network and clean up", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, "test-network-"); + await deletePoliciesByGroupName(page, policySourceGroup); + await deletePoliciesBySubstring(page, "test-resource-"); + for (const group of [policySourceGroup, routingPeerGroup]) { + await deleteGroupsByPrefix(page, group); + } + }); +}); + +async function addResourceToNetwork( + page: import("@playwright/test").Page, + prefix: string, + address: string, +) { + // Page should already be on /networks from previous test + const row = page.locator("tr").filter({ hasText: networkName }); + await expect(row).toBeVisible(); + + const name = generateRandomName(prefix); + // The per-row resource-add affordance is now an icon "Add" button. + await row.getByTestId("add-resource").click(); + + await expect(page.getByTestId("resource-name-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("resource-name-input").fill(name); + await page.getByTestId("resource-address-input").fill(address); + await page.getByTestId("resource-continue").click(); + + await page.getByTestId("submit-resource").click(); + // "No policies configured" warning + await page.getByTestId("confirmation.confirm").click(); + // "Add Routing Peer?" prompt — wait for it and dismiss + await page.getByTestId("confirmation.cancel").click(); +} diff --git a/e2e/tests/reverse-proxy-crowdsec.spec.ts b/e2e/tests/reverse-proxy-crowdsec.spec.ts new file mode 100644 index 0000000..d311705 --- /dev/null +++ b/e2e/tests/reverse-proxy-crowdsec.spec.ts @@ -0,0 +1,167 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { + gotoReverseProxyPage, + selectL4Resource, + selectProxyDomain, + openServiceEdit, + deleteService, + resetServiceFilters, + CUSTOM_PORTS_DOMAIN, +} from "../helpers/reverse-proxy-l4"; + +const DOMAINS_GLOB = "**/reverse-proxies/domains"; + +// Force the test clusters to advertise CrowdSec support so the selector renders, +// independent of whether the test backend has CrowdSec configured. The save +// payload assertion below verifies the real wiring regardless of the backend. +async function forceCrowdSecSupport(page: import("@playwright/test").Page) { + await page.route(DOMAINS_GLOB, async (route) => { + if (route.request().method() !== "GET") return route.continue(); + const response = await route.fetch(); + let body: any; + try { + body = await response.json(); + } catch (e) { + return route.fulfill({ response }); + } + if (Array.isArray(body)) { + body = body.map((d) => ({ ...d, supports_crowdsec: true })); + } + return route.fulfill({ response, json: body }); + }); +} + +test.describe.serial("Reverse Proxy - CrowdSec @reverse-proxy", () => { + let network = ""; + let resource = ""; + let subdomain = ""; + + test("Should configure CrowdSec on a service and send crowdsec_mode on save", async ({ + dashboardAsOwner: page, + }) => { + test.setTimeout(90_000); + await forceCrowdSecSupport(page); + await deleteServicesByPrefix(page, "crowdsec-svc-"); + await deleteNetworksByPrefix(page, "rp-crowdsec-net-"); + + // Create a network with a resource (same inline flow as the L4 specs). + await navigateTo(page, "/networks"); + network = generateRandomName("rp-crowdsec-net-"); + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(network); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + resource = generateRandomName("rp-resource-"); + await page.getByTestId("resource-name-input").fill(resource); + await page.getByTestId("resource-address-input").fill("10.99.99.40"); + await page.getByTestId("resource-continue").click(); + const resourcePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await resourcePromise; + const cancelBtn = page.getByTestId("confirmation.cancel"); + if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await cancelBtn.click({ force: true }); + } + + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + subdomain = generateRandomName("crowdsec-svc-"); + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); + await page + .getByTestId("service-mode-select-button") + .click({ force: true }); + await page.getByTestId("service-mode-option-tcp").click({ force: true }); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ + timeout: 10_000, + }); + await selectL4Resource(page, resource); + await expect(page.getByTestId("listen-port-input")).toBeEnabled({ + timeout: 10_000, + }); + await page.getByTestId("listen-port-input").fill("3306"); + await page.getByTestId("destination-port-input").fill("3306"); + await page.getByTestId("proxy-continue").click(); + + // Access control step: the CrowdSec selector renders for supporting clusters. + const crowdsecTrigger = page.getByTestId("crowdsec-mode-trigger"); + await expect(crowdsecTrigger).toBeVisible({ timeout: 10_000 }); + await crowdsecTrigger.click({ force: true }); + await page.getByTestId("crowdsec-mode-enforce").click({ force: true }); + await expect(crowdsecTrigger).toContainText("Enforce"); + + await page.getByTestId("proxy-continue").click(); + + const savePromise = page.waitForResponse( + (resp) => + resp.url().includes("/reverse-proxies/services") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-service").click(); + const saveResp = await savePromise; + + // Core assertion: the configured mode is included in the save payload. + const payload = saveResp.request().postDataJSON(); + expect( + payload?.access_restrictions?.crowdsec_mode, + "crowdsec_mode should be sent in the service payload", + ).toBe("enforce"); + + await resetServiceFilters(page); + await expect( + page.locator("tr").filter({ hasText: subdomain }), + ).toBeVisible({ timeout: 30_000 }); + }); + + test("Should show CrowdSec in the access control cell and persist on reopen", async ({ + dashboardAsOwner: page, + }) => { + test.setTimeout(60_000); + await forceCrowdSecSupport(page); + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + await resetServiceFilters(page); + + // The access control cell counts CrowdSec as a rule and lists it on hover. + const cell = page + .locator("tr") + .filter({ hasText: subdomain }) + .locator("[data-access-control-cell]"); + await expect(cell).toContainText("1", { timeout: 10_000 }); + + // Reopen the service: the selector reflects the persisted Enforce mode. + await openServiceEdit(page, subdomain); + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await expect(page.getByTestId("crowdsec-mode-trigger")).toContainText( + "Enforce", + { timeout: 10_000 }, + ); + await page.keyboard.press("Escape"); + }); + + test("Should clean up the CrowdSec service and network", async ({ + dashboardAsOwner: page, + }) => { + await forceCrowdSecSupport(page); + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + await resetServiceFilters(page); + await deleteService(page, subdomain); + await deleteNetworksByPrefix(page, network); + await page.unroute(DOMAINS_GLOB); + }); +}); diff --git a/e2e/tests/reverse-proxy-custom-domains.spec.ts b/e2e/tests/reverse-proxy-custom-domains.spec.ts new file mode 100644 index 0000000..1ad918b --- /dev/null +++ b/e2e/tests/reverse-proxy-custom-domains.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { applyRadioTableFilter, generateRandomName } from "../helpers/utils"; +import { gotoReverseProxyPage } from "../helpers/reverse-proxy-l4"; + +let domain = ""; +const TARGET_CLUSTER = "example.com"; + +test.describe.serial("Reverse Proxy - Custom Domains @reverse-proxy", () => { + test("Should validate domain input and add a custom domain", async ({ + dashboardAsOwner: page, + }) => { + await gotoReverseProxyPage(page, "/reverse-proxy/custom-domains"); + + await page.getByTestId("add-custom-domain").click(); + await expect(page.getByTestId("custom-domain-input")).toBeVisible(); + + // Invalid input should show error + await page.getByTestId("custom-domain-input").fill("mycustomdomain"); + await page.getByTestId("custom-domain-input").blur(); + await expect(page.getByText("Please enter a valid TLD domain")).toBeVisible(); + + // Fill valid domain — error should disappear + const prefix = generateRandomName("mycustomdomain-"); + domain = `${prefix}.com`; + await page.getByTestId("custom-domain-input").fill(domain); + await expect(page.getByText("Please enter a valid TLD domain")).toHaveCount(0); + + // Pick the target proxy cluster explicitly — with multiple clusters the + // dashboard does not auto-select. + const clusterSection = page.getByTestId("custom-domain-cluster-selector"); + await clusterSection.locator("button").first().click({ force: true }); + await page + .locator('[role="option"]') + .filter({ has: page.getByText(TARGET_CLUSTER, { exact: true }) }) + .first() + .click({ force: true }); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/reverse-proxies/domains") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-custom-domain").click(); + const response = await responsePromise; + expect([200, 201]).toContain(response.status()); + + await expect(page.getByRole("heading", { name: "Verify Domain" })).toBeVisible(); + await expect(page.getByText(`*.${domain}`)).toBeVisible(); + + await page.getByTestId("verify-domain-later").click(); + + const row = page.locator("tr").filter({ hasText: domain }); + await expect(row).toBeVisible(); + await expect(row).toContainText("Pending Verification"); + await expect(row).toContainText(TARGET_CLUSTER); + }); + + test("Should filter domains by Pending and Active", async ({ dashboardAsOwner: page }) => { + await applyRadioTableFilter(page, "validated", "Pending"); + await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible(); + + await applyRadioTableFilter(page, "validated", "Active"); + await expect(page.locator("tr").filter({ hasText: domain })).not.toBeVisible(); + + await applyRadioTableFilter(page, "validated", "All"); + await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible(); + }); + + test("Should search for the domain", async ({ dashboardAsOwner: page }) => { + const searchInput = page.getByTestId("table-search-input"); + await searchInput.fill(domain); + await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible(); + await searchInput.fill(""); + }); + + test("Should delete the custom domain", async ({ dashboardAsOwner: page }) => { + await page + .locator("tr") + .filter({ hasText: domain }) + .getByTestId("delete-custom-domain") + .click(); + await page.getByTestId("confirmation.confirm").click(); + await expect(page.locator("tr").filter({ hasText: domain })).not.toBeVisible(); + }); +}); diff --git a/e2e/tests/reverse-proxy-services-https.spec.ts b/e2e/tests/reverse-proxy-services-https.spec.ts new file mode 100644 index 0000000..33a403d --- /dev/null +++ b/e2e/tests/reverse-proxy-services-https.spec.ts @@ -0,0 +1,275 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { gotoReverseProxyPage, selectProxyDomain, CUSTOM_PORTS_DOMAIN } from "../helpers/reverse-proxy-l4"; + +let createdNetwork = ""; +let createdResource = ""; +let createdSubdomain = ""; + +test.describe.serial("Reverse Proxy - Services (HTTPS) @reverse-proxy", () => { + test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + // Clean up leftovers from previous runs (unique prefix per protocol) + await deleteServicesByPrefix(page, "https-svc-"); + await deleteNetworksByPrefix(page, "rp-https-net-"); + await navigateTo(page, "/networks"); + const name = generateRandomName("rp-https-net-"); + createdNetwork = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + // Add resource + const resName = generateRandomName("rp-resource-"); + createdResource = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.99.99.10"); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await responsePromise; + await page.getByTestId("confirmation.cancel").click({ force: true }); + }); + + test("Should create an HTTPS reverse proxy service with full configuration", async ({ + dashboardAsOwner: page, + }) => { + test.setTimeout(60_000); + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + const subdomain = generateRandomName("https-svc-"); + createdSubdomain = subdomain; + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + + // Step 1: Service + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); + + // Add 2 targets: http with options, https with port + await addTarget(page, { + resourceName: createdResource, + protocol: "http", + timeout: "10s", + customHeader: { name: "X-Custom-Header", value: "custom-value" }, + }); + await addTarget(page, { + resourceName: createdResource, + location: "/secure", + protocol: "https", + port: 4433, + }); + + const targetsSection = page.getByText("HTTPS Targets").locator(".."); + await expect(targetsSection.locator("table tbody tr")).toHaveCount(2); + + await page.getByTestId("proxy-continue").click(); + + // Step 2: Authentication + await page.getByTestId("auth-sso-card").click(); + await page.getByTestId("submit-sso").click(); + + await page.getByTestId("auth-password-card").click(); + await page.getByTestId("password-input").fill("super-secret-pass"); + await page.getByTestId("submit-password").click(); + + await page.getByTestId("auth-pin-card").click(); + const pinInputs = page.locator('input[inputmode="numeric"][maxlength="1"]'); + for (let i = 0; i < 6; i++) { + await pinInputs.nth(i).fill(String(i + 1), { force: true }); + } + await page.getByTestId("submit-pin").click(); + + await page.getByTestId("auth-header-card").click(); + await page.getByTestId("header-type-select").click(); + await page.locator("[cmdk-list]").getByText("Basic Auth").click({ force: true }); + await page.getByTestId("header-basic-username").fill("admin"); + await page.getByTestId("header-basic-password").fill("admin-pass"); + await page.getByTestId("submit-headers").click(); + + await page.getByTestId("proxy-continue").click(); + + // Step 3: Access Control + await page.getByTestId("add-access-rule").click(); + await page.getByTestId("access-rule-0").getByText("Select country...").click(); + await page.getByTestId("select-dropdown-search").fill("Germany"); + await page.getByText("Germany (DE)").click({ force: true }); + + await page.getByTestId("add-access-rule").click(); + await page.getByTestId("access-rule-1").getByTestId("access-rule-action").click(); + await page.getByText("Block Only").click({ force: true }); + await page.getByTestId("access-rule-1").getByTestId("access-rule-type").click(); + await page.locator('[role="option"]').filter({ hasText: "IP Address" }).click({ force: true }); + const ipInput = page.getByTestId("access-rule-1").getByTestId("access-rule-value"); + await expect(ipInput).toBeVisible(); + await ipInput.fill("85.203.15.42"); + + await page.getByTestId("proxy-continue").click(); + + // Step 4: Advanced Settings + await page.getByTestId("toggle-pass-host-header").click(); + await page.getByTestId("toggle-rewrite-redirects").click(); + await page.getByTestId("submit-service").click(); + + await expect(page.locator("tr").filter({ hasText: subdomain })).toBeVisible({ timeout: 30_000 }); + }); + + test("Should edit the service, remove auth and rules, then delete", async ({ + dashboardAsOwner: page, + }) => { + await resetServiceFilters(page); + await page.locator("tr").filter({ hasText: createdSubdomain }).getByTestId("service-actions").click({ force: true }); + await page.getByTestId("edit-service").click({ force: true }); + + // Edit first target + const targetsSection = page.getByText("HTTPS Targets").locator(".."); + await targetsSection.locator("table tbody tr").first().click({ force: true }); + await page.getByTestId("target-location-input").fill("/new-location"); + await page.getByTestId("submit-target").click(); + + // Remove second target + await targetsSection.locator("table tbody tr").filter({ hasText: "/secure" }).getByTestId("target-row-actions").click(); + await page.getByTestId("remove-target").click(); + await expect(targetsSection.locator("table tbody tr")).toHaveCount(1); + + // Remove all auth methods — click Edit on each card, then Remove in the modal + await page.getByTestId("proxy-tab-auth").click({ force: true }); + await removeAuthMethod(page, "auth-sso-card", "remove-sso"); + await removeAuthMethod(page, "auth-password-card", "remove-password"); + await removeAuthMethod(page, "auth-pin-card", "remove-pin"); + await removeAuthMethod(page, "auth-header-card", "remove-headers"); + + // Remove access control rules + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await page.getByTestId("remove-access-rule").last().click({ force: true }); + await page.getByTestId("remove-access-rule").first().click({ force: true }); + + // Toggle advanced settings back + await page.getByTestId("proxy-tab-settings").click({ force: true }); + await page.getByTestId("toggle-pass-host-header").click({ force: true }); + await page.getByTestId("toggle-rewrite-redirects").click({ force: true }); + + // Save and wait for API response + const saveResponse = page.waitForResponse( + (resp) => + resp.url().includes("/api/reverse-proxies/services") && + resp.request().method() === "PUT", + { timeout: 15_000 }, + ); + await page.getByTestId("proxy-save").click(); + const confirmBtn = page.getByTestId("confirmation.confirm"); + if (await confirmBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { + await confirmBtn.click({ force: true }); + } + await saveResponse; + + // Verify no auth / no access rules: both cells now show a "0" count badge. + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: createdSubdomain }); + await expect(row.locator("[data-auth-cell]")).toContainText("0", { + timeout: 15_000, + }); + await expect( + row.locator("[data-access-control-cell]"), + ).toContainText("0", { timeout: 15_000 }); + + // Delete the service + await row.getByTestId("service-actions").click({ force: true }); + await page.getByTestId("delete-service").click({ force: true }); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await expect(row).not.toBeVisible(); + }); + + test("Should delete the network", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, createdNetwork); + }); +}); + +async function resetServiceFilters(page: import("@playwright/test").Page) { + const resetBtn = page.getByTestId("reset-filters-and-search"); + if (await resetBtn.isVisible().catch(() => false)) { + await resetBtn.click(); + } +} + +type AddTargetOptions = { + resourceName: string; + location?: string; + protocol?: "http" | "https"; + port?: number; + timeout?: string; + customHeader?: { name: string; value: string }; +}; + +async function addTarget(page: import("@playwright/test").Page, opts: AddTargetOptions) { + await page.getByTestId("add-target").scrollIntoViewIfNeeded(); + await page.getByTestId("add-target").click(); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + + await page.getByTestId("group-selector-dropdown").click(); + await page.locator('[role="tab"]').filter({ hasText: "Resources" }).click({ force: true }); + const search = page.getByTestId("group-selector-dropdown-search"); + await expect(search).toBeVisible({ timeout: 5_000 }); + await search.fill(opts.resourceName); + await page.getByText(opts.resourceName).click({ force: true, timeout: 15_000 }); + await expect(page.getByTestId("target-port-input")).toBeVisible({ timeout: 10_000 }); + + if (opts.location) { + await expect(page.getByTestId("target-location-input")).toBeEnabled({ timeout: 5_000 }); + await page.getByTestId("target-location-input").fill(opts.location); + } + + if (opts.protocol === "https") { + await page.getByTestId("target-protocol-select").click(); + await page.locator("[cmdk-list]").getByText("https://").click({ force: true }); + } + + if (opts.port !== undefined) { + await page.getByTestId("target-port-input").fill(String(opts.port)); + } else { + await page.getByTestId("target-port-input").fill(""); + } + + if (opts.timeout || opts.customHeader) { + await page.getByTestId("target-optional-settings").click(); + if (opts.timeout) { + await page.getByTestId("target-timeout-input").fill(opts.timeout); + } + if (opts.customHeader) { + await page.getByTestId("add-custom-header").click(); + await page.getByTestId("custom-header-name-0").fill(opts.customHeader.name); + await page.getByTestId("custom-header-value-0").fill(opts.customHeader.value); + } + } + + await page.getByTestId("submit-target").click(); +} + +async function removeAuthMethod( + page: import("@playwright/test").Page, + cardTestId: string, + removeTestId: string, +) { + const card = page.getByTestId(cardTestId); + const removeBtn = page.getByTestId(removeTestId); + + // Click the card to open the auth modal + await card.click(); + await expect(removeBtn).toBeVisible(); + await removeBtn.click(); + + // Wait for the modal to fully close — the remove button must disappear + // and the "Enabled" badge on the card should also disappear + await expect(removeBtn).not.toBeVisible(); + await expect(card.getByText("Enabled")).not.toBeVisible(); +} diff --git a/e2e/tests/reverse-proxy-services-tcp.spec.ts b/e2e/tests/reverse-proxy-services-tcp.spec.ts new file mode 100644 index 0000000..5b54edd --- /dev/null +++ b/e2e/tests/reverse-proxy-services-tcp.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { + gotoReverseProxyPage, + selectL4Resource, + addAccessControlRules, + removeAllAccessControlRules, + resetServiceFilters, + openServiceEdit, + deleteService, + saveServiceEdit, + selectProxyDomain, + CUSTOM_PORTS_DOMAIN, +} from "../helpers/reverse-proxy-l4"; + +let tcpNetwork = ""; +let tcpResource = ""; +let tcpSubdomain = ""; + +test.describe.serial("Reverse Proxy - Services (TCP) @reverse-proxy", () => { + test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + await deleteServicesByPrefix(page, "tcp-svc-"); + await deleteNetworksByPrefix(page, "rp-tcp-net-"); + await navigateTo(page, "/networks"); + + const name = generateRandomName("rp-tcp-net-"); + tcpNetwork = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + const resName = generateRandomName("rp-resource-"); + tcpResource = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.99.99.30"); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await responsePromise; + const cancelBtn = page.getByTestId("confirmation.cancel"); + if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await cancelBtn.click({ force: true }); + } + }); + + test("Should create a TCP service", async ({ dashboardAsOwner: page }) => { + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + const subdomain = generateRandomName("tcp-svc-"); + tcpSubdomain = subdomain; + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); + await page.getByTestId("service-mode-select-button").click({ force: true }); + await page.getByTestId("service-mode-option-tcp").click({ force: true }); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + + await selectL4Resource(page, tcpResource); + await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 }); + await page.getByTestId("listen-port-input").fill("3306"); + await page.getByTestId("destination-port-input").fill("3306"); + await page.getByTestId("proxy-continue").click(); + + await addAccessControlRules(page); + await page.getByTestId("proxy-continue").click(); + + await page.getByTestId("connection-timeout-input").fill("20s"); + await page.getByTestId("toggle-preserve-client-ip").click(); + await page.getByTestId("submit-service").click(); + + await resetServiceFilters(page); + await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("TCP", { exact: true })).toBeVisible({ timeout: 30_000 }); + }); + + test("Should edit the TCP service and delete it", async ({ dashboardAsOwner: page }) => { + await openServiceEdit(page, tcpSubdomain); + + await page.getByTestId("listen-port-input").fill("5432"); + await page.getByTestId("destination-port-input").fill("5432"); + + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await removeAllAccessControlRules(page); + + await page.getByTestId("proxy-tab-settings").click({ force: true }); + await page.getByTestId("toggle-preserve-client-ip").click({ force: true }); + await page.getByTestId("connection-timeout-input").fill("15s"); + + await saveServiceEdit(page); + + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: tcpSubdomain }); + await expect(row.locator("[data-access-control-cell]")).toContainText( + "0", + { timeout: 10_000 }, + ); + + await deleteService(page, tcpSubdomain); + }); + + test("Should delete the network", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, tcpNetwork); + }); +}); diff --git a/e2e/tests/reverse-proxy-services-tls.spec.ts b/e2e/tests/reverse-proxy-services-tls.spec.ts new file mode 100644 index 0000000..f0389b1 --- /dev/null +++ b/e2e/tests/reverse-proxy-services-tls.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { + gotoReverseProxyPage, + selectL4Resource, + addAccessControlRules, + removeAllAccessControlRules, + resetServiceFilters, + openServiceEdit, + deleteService, + saveServiceEdit, + selectProxyDomain, + CUSTOM_PORTS_DOMAIN, +} from "../helpers/reverse-proxy-l4"; + +let tlsNetwork = ""; +let tlsResource = ""; +let tlsSubdomain = ""; + +test.describe.serial("Reverse Proxy - Services (TLS Passthrough) @reverse-proxy", () => { + test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + // Clean up leftover networks + await deleteServicesByPrefix(page, "tls-svc-"); + await deleteNetworksByPrefix(page, "rp-tls-net-"); + await navigateTo(page, "/networks"); + + // Create network + const name = generateRandomName("rp-tls-net-"); + tlsNetwork = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + // Add resource directly from the confirmation flow + const resName = generateRandomName("rp-resource-"); + tlsResource = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.99.99.20"); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await responsePromise; + // "Add Routing Peer?" prompt may or may not appear + const cancelBtn = page.getByTestId("confirmation.cancel"); + if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await cancelBtn.click({ force: true }); + } + }); + + test("Should create a TLS Passthrough service", async ({ dashboardAsOwner: page }) => { + test.setTimeout(60_000); + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + const subdomain = generateRandomName("tls-svc-"); + tlsSubdomain = subdomain; + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); + await page.getByTestId("service-mode-select-button").click({ force: true }); + await page.getByTestId("service-mode-option-tls").click({ force: true }); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + + await selectL4Resource(page, tlsResource); + await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 }); + await page.getByTestId("listen-port-input").fill("8443"); + await page.getByTestId("destination-port-input").fill("443"); + await page.getByTestId("proxy-continue").click(); + + await addAccessControlRules(page); + await page.getByTestId("proxy-continue").click(); + + await page.getByTestId("toggle-preserve-client-ip").click(); + await page.getByTestId("connection-timeout-input").fill("20s"); + await page.getByTestId("submit-service").click(); + + await resetServiceFilters(page); + await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("TLS Passthrough")).toBeVisible({ timeout: 30_000 }); + }); + + test("Should edit the TLS service and delete it", async ({ dashboardAsOwner: page }) => { + await openServiceEdit(page, tlsSubdomain); + + await page.getByTestId("listen-port-input").fill("9443"); + await page.getByTestId("destination-port-input").fill("8443"); + + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await removeAllAccessControlRules(page); + + await page.getByTestId("proxy-tab-settings").click({ force: true }); + await page.getByTestId("toggle-preserve-client-ip").click({ force: true }); + await page.getByTestId("connection-timeout-input").fill(""); + + await saveServiceEdit(page); + + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: tlsSubdomain }); + await expect(row.locator("[data-access-control-cell]")).toContainText("0"); + + await deleteService(page, tlsSubdomain); + }); + + test("Should delete the network", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, tlsNetwork); + }); +}); diff --git a/e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts b/e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts new file mode 100644 index 0000000..653cc02 --- /dev/null +++ b/e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { + gotoReverseProxyPage, + selectL4Resource, + addAccessControlRules, + removeAllAccessControlRules, + resetServiceFilters, + openServiceEdit, + deleteService, + saveServiceEdit, + selectProxyDomain, + NO_CUSTOM_PORTS_DOMAIN, +} from "../helpers/reverse-proxy-l4"; + +let udpNetwork = ""; +let udpResource = ""; +let udpSubdomain = ""; + +test.describe.serial("Reverse Proxy - Services (UDP, no custom ports) @reverse-proxy", () => { + test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + await deleteServicesByPrefix(page, "udp-np-svc-"); + await deleteNetworksByPrefix(page, "rp-udp-np-net-"); + await navigateTo(page, "/networks"); + + const name = generateRandomName("rp-udp-np-net-"); + udpNetwork = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + const resName = generateRandomName("rp-resource-"); + udpResource = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.99.99.41"); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await responsePromise; + const cancelBtn = page.getByTestId("confirmation.cancel"); + if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await cancelBtn.click({ force: true }); + } + }); + + test("Should create a UDP service on the no-custom-ports cluster", async ({ dashboardAsOwner: page }) => { + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + const subdomain = generateRandomName("udp-np-svc-"); + udpSubdomain = subdomain; + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + + await selectProxyDomain(page, NO_CUSTOM_PORTS_DOMAIN); + + await page.getByTestId("service-mode-select-button").click({ force: true }); + await page.getByTestId("service-mode-option-udp").click({ force: true }); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + + await selectL4Resource(page, udpResource); + + // Listen port is auto-assigned when the cluster has custom ports disabled + await expect(page.getByTestId("listen-port-input")).toBeDisabled({ timeout: 10_000 }); + await expect(page.getByTestId("listen-port-input")).toHaveAttribute("placeholder", "Auto"); + + await page.getByTestId("destination-port-input").fill("5060"); + await page.getByTestId("proxy-continue").click(); + + await addAccessControlRules(page); + await page.getByTestId("proxy-continue").click(); + + await page.getByTestId("connection-timeout-input").fill("30s"); + await page.getByTestId("submit-service").click(); + + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: subdomain }); + await expect(row.getByText("UDP", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(row).toContainText(NO_CUSTOM_PORTS_DOMAIN); + }); + + test("Should edit the UDP service and delete it", async ({ dashboardAsOwner: page }) => { + await openServiceEdit(page, udpSubdomain); + + // Listen port must remain auto-assigned on this cluster + await expect(page.getByTestId("listen-port-input")).toBeDisabled(); + + await page.getByTestId("destination-port-input").fill("5061"); + + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await removeAllAccessControlRules(page); + + await page.getByTestId("proxy-tab-settings").click({ force: true }); + await page.getByTestId("connection-timeout-input").fill(""); + + await saveServiceEdit(page); + + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: udpSubdomain }); + await expect(row.locator("[data-access-control-cell]")).toContainText("0"); + + await deleteService(page, udpSubdomain); + }); + + test("Should delete the network", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, udpNetwork); + }); +}); diff --git a/e2e/tests/reverse-proxy-services-udp.spec.ts b/e2e/tests/reverse-proxy-services-udp.spec.ts new file mode 100644 index 0000000..7cc39cd --- /dev/null +++ b/e2e/tests/reverse-proxy-services-udp.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { + gotoReverseProxyPage, + selectL4Resource, + addAccessControlRules, + removeAllAccessControlRules, + resetServiceFilters, + openServiceEdit, + deleteService, + saveServiceEdit, + selectProxyDomain, + CUSTOM_PORTS_DOMAIN, +} from "../helpers/reverse-proxy-l4"; + +let udpNetwork = ""; +let udpResource = ""; +let udpSubdomain = ""; + +test.describe.serial("Reverse Proxy - Services (UDP) @reverse-proxy", () => { + test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + await deleteServicesByPrefix(page, "udp-svc-"); + await deleteNetworksByPrefix(page, "rp-udp-net-"); + await navigateTo(page, "/networks"); + + const name = generateRandomName("rp-udp-net-"); + udpNetwork = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + const resName = generateRandomName("rp-resource-"); + udpResource = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.99.99.40"); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await responsePromise; + const cancelBtn = page.getByTestId("confirmation.cancel"); + if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await cancelBtn.click({ force: true }); + } + }); + + test("Should create a UDP service", async ({ dashboardAsOwner: page }) => { + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + const subdomain = generateRandomName("udp-svc-"); + udpSubdomain = subdomain; + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); + await page.getByTestId("service-mode-select-button").click({ force: true }); + await page.getByTestId("service-mode-option-udp").click({ force: true }); + // Wait for mode switch to take effect + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + + await selectL4Resource(page, udpResource); + await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 }); + await page.getByTestId("listen-port-input").fill("5060"); + await page.getByTestId("destination-port-input").fill("5060"); + await page.getByTestId("proxy-continue").click(); + + await addAccessControlRules(page); + await page.getByTestId("proxy-continue").click(); + + await page.getByTestId("connection-timeout-input").fill("30s"); + await page.getByTestId("submit-service").click(); + + await resetServiceFilters(page); + await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("UDP", { exact: true })).toBeVisible({ timeout: 30_000 }); + }); + + test("Should edit the UDP service and delete it", async ({ dashboardAsOwner: page }) => { + await openServiceEdit(page, udpSubdomain); + + await page.getByTestId("listen-port-input").fill("5061"); + await page.getByTestId("destination-port-input").fill("5061"); + + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await removeAllAccessControlRules(page); + + await page.getByTestId("proxy-tab-settings").click({ force: true }); + await page.getByTestId("connection-timeout-input").fill(""); + + await saveServiceEdit(page); + + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: udpSubdomain }); + await expect(row.locator("[data-access-control-cell]")).toContainText("0"); + + await deleteService(page, udpSubdomain); + }); + + test("Should delete the network", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, udpNetwork); + }); +}); diff --git a/e2e/tests/settings-authentication.spec.ts b/e2e/tests/settings-authentication.spec.ts new file mode 100644 index 0000000..d60bc60 --- /dev/null +++ b/e2e/tests/settings-authentication.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; + +test.describe.serial("Settings - Authentication @settings", () => { + test("Should toggle peer approval", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/settings"); + await toggleAndSave(page, "peer-approval"); + }); + + test("Should toggle peer login expiration off and back on", async ({ + dashboardAsOwner: page, + }) => { + await toggleAndSave(page, "peer-login-expiration"); + await toggleAndSave(page, "peer-login-expiration"); + }); + + test("Should change peer login expiration time", async ({ dashboardAsOwner: page }) => { + await ensureToggleState(page, "peer-login-expiration", "checked"); + + // Use a value different from current to ensure the save button enables + const currentValue = await page.getByTestId("peer-login-expiration-input").inputValue(); + const hoursValue = currentValue === "17" ? "22" : "17"; + + await page.getByTestId("peer-login-expiration-input").fill(hoursValue); + await page.getByTestId("peer-login-expiration-select").click(); + await page + .getByTestId("peer-login-expiration-select-content") + .getByText("Hours") + .click(); + await save(page); + await expect(page.getByTestId("peer-login-expiration-input")).toHaveValue(hoursValue); + + // Change to a different days value + const currentDays = await page.getByTestId("peer-login-expiration-input").inputValue(); + const daysValue = currentDays === "180" ? "90" : "180"; + + await page.getByTestId("peer-login-expiration-input").fill(daysValue); + await page.getByTestId("peer-login-expiration-select").click(); + await page + .getByTestId("peer-login-expiration-select-content") + .getByText("Days") + .click(); + await save(page); + await expect(page.getByTestId("peer-login-expiration-input")).toHaveValue(daysValue); + await expect(page.getByTestId("peer-login-expiration-select-value")).toContainText("Days"); + }); + + test("Should toggle peer inactivity expiration", async ({ dashboardAsOwner: page }) => { + await toggleAndSave(page, "peer-inactivity-expiration"); + }); +}); + +async function save(page: import("@playwright/test").Page) { + await page.getByTestId("save-authentication-settings").click(); + await expect(page.getByText("successfully saved").first()).toBeVisible(); +} + +async function toggleAndSave( + page: import("@playwright/test").Page, + name: string, +) { + const toggle = page.getByTestId(name); + const initialState = await toggle.getAttribute("data-state"); + const expectedState = initialState === "checked" ? "unchecked" : "checked"; + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expectedState); + await save(page); +} + +async function ensureToggleState( + page: import("@playwright/test").Page, + name: string, + desiredState: "checked" | "unchecked", +) { + const toggle = page.getByTestId(name); + const currentState = await toggle.getAttribute("data-state"); + if (currentState !== desiredState) { + await toggle.click(); + } +} diff --git a/e2e/tests/settings-clients.spec.ts b/e2e/tests/settings-clients.spec.ts new file mode 100644 index 0000000..b8c6592 --- /dev/null +++ b/e2e/tests/settings-clients.spec.ts @@ -0,0 +1,124 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let peerExposeGroup = ""; + +test.describe.serial("Settings - Clients @settings", () => { + test("Should set automatic updates to Latest Version with force updates", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/settings?tab=clients"); + + // Ensure we start from Disabled so the change to Latest Version is always detected + const currentMethod = await page.getByTestId("auto-update-method").textContent(); + if (currentMethod?.includes("Latest")) { + await selectAutoUpdateMethod(page, "Disabled"); + await save(page); + } + + await selectAutoUpdateMethod(page, "Latest Version"); + const forceToggle = page.getByTestId("force-auto-updates"); + if ((await forceToggle.getAttribute("data-state")) !== "checked") { + await forceToggle.click(); + } + await expect(forceToggle).toHaveAttribute("data-state", "checked"); + await save(page); + }); + + test("Should switch to Custom Version and disable force updates", async ({ + dashboardAsOwner: page, + }) => { + await page.getByTestId("force-auto-updates").click(); + await expect(page.getByTestId("force-auto-updates")).toHaveAttribute("data-state", "unchecked"); + + await selectAutoUpdateMethod(page, "Custom Version"); + await page.getByTestId("auto-update-version-input").fill("0.5"); + await save(page); + }); + + test("Should set automatic updates back to Disabled", async ({ dashboardAsOwner: page }) => { + await selectAutoUpdateMethod(page, "Disabled"); + await save(page); + await expect(page.getByTestId("auto-update-version-input")).toBeDisabled(); + }); + + test("Should enable peer expose with a group", async ({ dashboardAsOwner: page }) => { + // Ensure peer expose starts disabled for a clean test + const toggle = page.getByTestId("peer-expose"); + if ((await toggle.getAttribute("data-state")) === "checked") { + // Remove any existing groups first + const badges = page.getByTestId("group-badge"); + const count = await badges.count(); + for (let i = 0; i < count; i++) { + await badges.first().click(); + } + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", "unchecked"); + await save(page); + } + + // Now enable and add group + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", "checked"); + + const name = generateRandomName("expose-group-"); + peerExposeGroup = name; + await page.getByTestId("peer-expose-groups-selector").click(); + const search = page.getByTestId("peer-expose-groups-selector-search"); + await search.fill(name); + await search.press("Enter"); + await search.press("Escape"); + await save(page); + }); + + test("Should remove the group and disable peer expose", async ({ dashboardAsOwner: page }) => { + const toggle = page.getByTestId("peer-expose"); + + // Remove the group badge if it exists + const badge = page.getByTestId("group-badge").filter({ hasText: peerExposeGroup }); + if (await badge.first().isVisible().catch(() => false)) { + await badge.first().click(); + await expect(badge).not.toBeVisible({ timeout: 5_000 }); + } + + // Disable peer expose if enabled + if ((await toggle.getAttribute("data-state")) === "checked") { + await toggle.click(); + } + await expect(toggle).toHaveAttribute("data-state", "unchecked"); + await save(page); + + // Verify peer expose persisted after save + await page.reload(); + await expect(page.getByTestId("peer-expose")).toHaveAttribute("data-state", "unchecked", { timeout: 10_000 }); + await expect(page.getByTestId("peer-expose")).toHaveAttribute("data-state", "unchecked"); + }); + + test("Should toggle lazy connections on and off", async ({ dashboardAsOwner: page }) => { + const toggle = page.getByTestId("lazy-connections"); + await toggle.click(); + await expect(page.getByText("successfully").first()).toBeVisible(); + await toggle.click(); + await expect(page.getByText("successfully").first()).toBeVisible(); + }); + + test("Should delete the created group", async ({ dashboardAsOwner: page }) => { + if (!peerExposeGroup) return; + await deleteGroupsByPrefix(page, peerExposeGroup); + }); +}); + +async function selectAutoUpdateMethod( + page: import("@playwright/test").Page, + label: string, +) { + await page.getByTestId("auto-update-method").click({ force: true }); + await page.locator("[cmdk-list]").getByText(label).click(); +} + +async function save(page: import("@playwright/test").Page) { + await page.getByTestId("save-clients-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); +} diff --git a/e2e/tests/settings-groups.spec.ts b/e2e/tests/settings-groups.spec.ts new file mode 100644 index 0000000..8780929 --- /dev/null +++ b/e2e/tests/settings-groups.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; + +test.describe.serial("Settings - Groups @settings", () => { + test("Should toggle user group propagation", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/settings?tab=groups"); + + const toggle = page.getByTestId("user-group-propagation"); + const initialState = await toggle.getAttribute("data-state"); + const expectedState = initialState === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expectedState); + + await page.getByTestId("save-groups-settings").click(); + await expect(page.getByText("updated successfully").first()).toBeVisible(); + await expect(toggle).toHaveAttribute("data-state", expectedState); + + // Toggle back to restore original state + await page.getByTestId("user-group-propagation").click(); + await page.getByTestId("save-groups-settings").click(); + await expect(page.getByText("updated successfully").first()).toBeVisible(); + }); +}); diff --git a/e2e/tests/settings-networks.spec.ts b/e2e/tests/settings-networks.spec.ts new file mode 100644 index 0000000..25d3e4f --- /dev/null +++ b/e2e/tests/settings-networks.spec.ts @@ -0,0 +1,240 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let trafficGroup = ""; +let ipv6Group = ""; + +test.describe.serial("Settings - Networks @settings", () => { + test("Should update DNS domain and network range", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/settings?tab=networks"); + + const origDomain = await page.getByTestId("dns-domain-input").inputValue(); + const origRange = await page.getByTestId("network-range-input").inputValue(); + + // Use values guaranteed to differ from current + const testDomain = origDomain === "test.internal" ? "test2.internal" : "test.internal"; + const testRange = origRange === "10.100.0.0/16" ? "10.200.0.0/16" : "10.100.0.0/16"; + + await page.getByTestId("dns-domain-input").fill(testDomain); + await page.getByTestId("network-range-input").fill(testRange); + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + + // Verify UI shows new values + await expect(page.getByTestId("dns-domain-input")).toHaveValue(testDomain); + await expect(page.getByTestId("network-range-input")).toHaveValue(testRange); + + // Revert + await page.getByTestId("dns-domain-input").fill(origDomain || "netbird.selfhosted"); + await page.getByTestId("network-range-input").fill(origRange || "100.64.0.0/10"); + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + }); + + test("Should toggle DNS wildcard routing", async ({ dashboardAsOwner: page }) => { + await toggleAndRevert(page, "dns-wildcard-routing"); + }); + + test("Should toggle traffic events", async ({ dashboardAsOwner: page }) => { + await toggleAndRevert(page, "traffic-events"); + }); + + test("Should toggle traffic reporting kernel", async ({ dashboardAsOwner: page }) => { + await ensureToggleState(page, "traffic-events", "checked"); + + const toggle = page.getByTestId("traffic-reporting-kernel"); + await expect(toggle).toBeVisible(); + + // Dispatch click via JS to bypass pointer-events interception from parent layout + await toggle.dispatchEvent("click"); + + // Confirmation dialog only appears when turning ON + const confirmBtn = page.getByTestId("confirmation.confirm"); + if (await confirmBtn.isVisible({ timeout: 2_000 }).catch(() => false)) { + await confirmBtn.click({ force: true }); + } + await expect(page.getByText("successfully").first()).toBeVisible(); + + // Toggle back + await page.getByTestId("traffic-reporting-kernel").dispatchEvent("click"); + if (await confirmBtn.isVisible({ timeout: 2_000 }).catch(() => false)) { + await confirmBtn.click({ force: true }); + } + await expect(page.getByText("successfully").first()).toBeVisible(); + }); + + test("Should add a group to traffic events and save", async ({ dashboardAsOwner: page }) => { + // Clean up stale groups from previous runs + await deleteGroupsByPrefix(page, "traffic-group-"); + await navigateTo(page, "/settings?tab=networks"); + + await ensureToggleState(page, "traffic-events", "checked"); + + // Scope to the traffic-events selector so we don't accidentally remove + // badges from other group selectors on the same page (e.g. IPv6 groups). + const trafficSelector = page.getByTestId("traffic-events-groups-selector"); + const existingBadges = trafficSelector.getByTestId("group-badge"); + const badgeCount = await existingBadges.count(); + for (let i = 0; i < badgeCount; i++) { + await existingBadges.first().click({ force: true }); + } + if (badgeCount > 0) { + await page.getByTestId("save-traffic-groups").click({ force: true }); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + } + + const name = generateRandomName("traffic-group-"); + trafficGroup = name; + + await page.getByTestId("traffic-events-groups-selector-open-close").click({ force: true }); + const search = page.getByTestId("traffic-events-groups-selector-search"); + await expect(search).toBeVisible({ timeout: 5_000 }); + await search.fill(name); + await search.press("Enter"); + if (await search.isVisible().catch(() => false)) { + await search.press("Escape"); + } + + await page.getByTestId("save-traffic-groups").click({ force: true }); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + + // Verify group is visible in UI within the traffic selector + await expect(trafficSelector.getByText(name).first()).toBeVisible(); + + // Remove the group (force needed due to parent pointer-events interception) + await trafficSelector.getByTestId("group-badge").filter({ hasText: name }).click({ force: true }); + await page.getByTestId("save-traffic-groups").click({ force: true }); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + }); + + test("Should delete the created traffic group", async ({ dashboardAsOwner: page }) => { + await deleteGroupsByPrefix(page, trafficGroup); + }); + + test("Should update the IPv6 network range", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/settings?tab=networks"); + + const input = page.getByTestId("network-range-v6-input"); + await expect(input).toBeVisible(); + const origRange = await input.inputValue(); + + // Pick a value guaranteed to differ from the current one + const testRange = + origRange === "fd00:1234::/64" ? "fd00:5678::/64" : "fd00:1234::/64"; + + await input.fill(testRange); + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + await expect(input).toHaveValue(testRange); + + // Revert + await input.fill(origRange); + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + await expect(input).toHaveValue(origRange); + }); + + test("Should reject an invalid IPv6 network range", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/settings?tab=networks"); + + const input = page.getByTestId("network-range-v6-input"); + const origRange = await input.inputValue(); + + // Prefix length outside the allowed /48..../112 window + await input.fill("fd00:1234::/32"); + await expect(page.getByTestId("save-network-settings")).toBeDisabled(); + + // Non-IPv6 string + await input.fill("not-an-ip"); + await expect(page.getByTestId("save-network-settings")).toBeDisabled(); + + // Restore so subsequent tests start from a clean state + await input.fill(origRange); + }); + + test("Should add and remove a group from IPv6 enabled groups", async ({ dashboardAsOwner: page }) => { + await deleteGroupsByPrefix(page, "ipv6-group-"); + await navigateTo(page, "/settings?tab=networks"); + + const ipv6Selector = page.getByTestId("ipv6-enabled-groups-selector"); + await expect(ipv6Selector).toBeVisible(); + + // Start from a clean slate: remove any existing badges scoped to this selector + const existingBadges = ipv6Selector.getByTestId("group-badge"); + const badgeCount = await existingBadges.count(); + for (let i = 0; i < badgeCount; i++) { + await existingBadges.first().click({ force: true }); + } + if (badgeCount > 0) { + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + } + + const name = generateRandomName("ipv6-group-"); + ipv6Group = name; + + await page.getByTestId("ipv6-enabled-groups-selector-open-close").click({ force: true }); + const search = page.getByTestId("ipv6-enabled-groups-selector-search"); + await expect(search).toBeVisible({ timeout: 5_000 }); + await search.fill(name); + await search.press("Enter"); + if (await search.isVisible().catch(() => false)) { + await search.press("Escape"); + } + + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + + // Verify the new group appears as a badge in the IPv6 selector + await expect( + ipv6Selector.getByTestId("group-badge").filter({ hasText: name }), + ).toBeVisible(); + + // Remove the group via the badge and save again + await ipv6Selector + .getByTestId("group-badge") + .filter({ hasText: name }) + .click({ force: true }); + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + await expect( + ipv6Selector.getByTestId("group-badge").filter({ hasText: name }), + ).not.toBeVisible(); + }); + + test("Should delete the created IPv6 group", async ({ dashboardAsOwner: page }) => { + await deleteGroupsByPrefix(page, ipv6Group); + }); +}); + +async function toggleAndRevert( + page: import("@playwright/test").Page, + name: string, +) { + const toggle = page.getByTestId(name); + const initialState = await toggle.getAttribute("data-state"); + const expectedState = initialState === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(page.getByText("successfully").first()).toBeVisible(); + await expect(toggle).toHaveAttribute("data-state", expectedState); + + // Toggle back + await toggle.click(); + await expect(page.getByText("successfully").first()).toBeVisible(); +} + +async function ensureToggleState( + page: import("@playwright/test").Page, + name: string, + desiredState: "checked" | "unchecked", +) { + const toggle = page.getByTestId(name); + const currentState = await toggle.getAttribute("data-state"); + if (currentState !== desiredState) { + await toggle.click(); + await expect(page.getByText("successfully").first()).toBeVisible(); + } +} diff --git a/e2e/tests/settings-notifications-email.spec.ts b/e2e/tests/settings-notifications-email.spec.ts new file mode 100644 index 0000000..55b145d --- /dev/null +++ b/e2e/tests/settings-notifications-email.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { deleteNotificationChannelsByType } from "../helpers/api"; + +const TEST_EMAIL = "notify@example.test"; + +test.describe.serial("Settings - Notifications - Email @notifications", () => { + test("Should add an email recipient", async ({ dashboardAsOwner: page }) => { + await deleteNotificationChannelsByType(page, "email"); + await navigateTo(page, "/settings?tab=notifications"); + await expect(page.getByTestId("notification-channel-email")).toBeVisible({ timeout: 15_000 }); + await page.getByTestId("notification-channel-email").click(); + await expect(page.getByTestId("notification-email-input")).toBeVisible({ timeout: 15_000 }); + + await page.getByTestId("notification-email-input").fill(TEST_EMAIL); + await page.getByTestId("notification-email-add").click(); + await expect( + page.getByTestId("notification-email-recipient").filter({ hasText: TEST_EMAIL }), + ).toBeVisible(); + }); + + test("Should toggle email channel enabled and verify on overview", async ({ + dashboardAsOwner: page, + }) => { + const toggle = page.locator('[data-testid="notification-email-enabled"]'); + if ((await toggle.getAttribute("data-state")) !== "checked") { + await toggle.click(); + } + await expect(toggle).toHaveAttribute("data-state", "checked"); + + await backToOverview(page); + await expect(page.getByTestId("notification-channel-email")).toContainText("Enabled"); + + await page.getByTestId("notification-channel-email").click(); + await page.locator('[data-testid="notification-email-enabled"]').click(); + await backToOverview(page); + await expect(page.getByTestId("notification-channel-email")).toContainText("Disabled"); + }); + + test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => { + await page.getByTestId("notification-channel-email").click(); + const toggle = page.getByTestId("notification-event-peer.pending.approval"); + const initial = await toggle.getAttribute("data-state"); + const expected = initial === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expected); + + // Toggle back to restore + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", initial!); + }); + + test("Should remove the email recipient and leave channel disabled", async ({ + dashboardAsOwner: page, + }) => { + await page + .getByTestId("notification-email-recipient") + .filter({ hasText: TEST_EMAIL }) + .click({ force: true }); + await expect( + page.getByTestId("notification-email-recipient").filter({ hasText: TEST_EMAIL }), + ).not.toBeVisible(); + + const toggle = page.locator('[data-testid="notification-email-enabled"]'); + if ((await toggle.getAttribute("data-state")) === "checked") { + await toggle.click(); + } + await expect(toggle).toHaveAttribute("data-state", "unchecked"); + }); +}); + +async function backToOverview(page: import("@playwright/test").Page) { + await page.getByTestId("breadcrumb-item").filter({ hasText: "Notifications" }).click(); +} diff --git a/e2e/tests/settings-notifications-slack.spec.ts b/e2e/tests/settings-notifications-slack.spec.ts new file mode 100644 index 0000000..fd58448 --- /dev/null +++ b/e2e/tests/settings-notifications-slack.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { deleteNotificationChannelsByType } from "../helpers/api"; + +test.describe.serial("Settings - Notifications - Slack @notifications", () => { + test("Should connect Slack through the 2-step wizard", async ({ dashboardAsOwner: page }) => { + await deleteNotificationChannelsByType(page, "slack"); + await navigateTo(page, "/settings?tab=notifications"); + await expect(page.getByTestId("notification-channel-slack")).toBeVisible({ timeout: 15_000 }); + await page.getByTestId("notification-channel-slack").click(); + await expect(page.getByTestId("slack-channel-connect")).toBeVisible({ timeout: 15_000 }); + + await page.getByTestId("slack-channel-connect").click(); + await expect(page.getByText("Create a Slack App")).toBeVisible(); + await page.getByTestId("slack-continue").click({ force: true }); + await expect(page.getByText("Configure Incoming Webhook")).toBeVisible(); + await page.getByTestId("slack-webhook-url-input").fill("https://hooks.slack.com/services/T000/B000/XXXX"); + await page.getByTestId("slack-connect").click(); + await expect(page.getByTestId("slack-actions")).toBeVisible(); + }); + + test("Should show Enabled on overview", async ({ dashboardAsOwner: page }) => { + await backToOverview(page); + await expect(page.getByTestId("notification-channel-slack")).toContainText("Enabled"); + }); + + test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => { + await page.getByTestId("notification-channel-slack").click(); + const toggle = page.getByTestId("notification-event-peer.pending.approval"); + const initial = await toggle.getAttribute("data-state"); + const expected = initial === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expected); + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", initial!); + }); + + test("Should disconnect Slack and show Disabled on overview", async ({ + dashboardAsOwner: page, + }) => { + await page.getByTestId("slack-actions").click({ force: true }); + await page.getByTestId("slack-disconnect").click({ force: true }); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await expect(page.getByTestId("slack-channel-connect")).toBeVisible(); + + await backToOverview(page); + await expect(page.getByTestId("notification-channel-slack")).toContainText("Disabled"); + }); +}); + +async function backToOverview(page: import("@playwright/test").Page) { + await page.getByTestId("breadcrumb-item").filter({ hasText: "Notifications" }).click(); +} \ No newline at end of file diff --git a/e2e/tests/settings-notifications-webhook.spec.ts b/e2e/tests/settings-notifications-webhook.spec.ts new file mode 100644 index 0000000..4c91e81 --- /dev/null +++ b/e2e/tests/settings-notifications-webhook.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { deleteNotificationChannelsByType } from "../helpers/api"; + +test.describe.serial("Settings - Notifications - Webhook @notifications", () => { + test("Should connect a webhook with no authentication", async ({ dashboardAsOwner: page }) => { + await deleteNotificationChannelsByType(page, "webhook"); + await navigateTo(page, "/settings?tab=notifications"); + await expect(page.getByTestId("notification-channel-webhook")).toBeVisible({ timeout: 15_000 }); + await page.getByTestId("notification-channel-webhook").click(); + await expect(page.getByTestId("webhook-connect")).toBeVisible({ timeout: 15_000 }); + + await page.getByTestId("webhook-connect").click(); + await page.getByTestId("webhook-url-input").fill("https://webhook.example/test"); + await expect(page.getByTestId("webhook-auth-type")).toContainText("No Authentication"); + await page.getByTestId("webhook-continue").click(); + await page.getByTestId("webhook-save").click(); + await expect(page.getByTestId("webhook-actions")).toBeVisible(); + }); + + test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => { + const toggle = page.getByTestId("notification-event-peer.pending.approval"); + const initial = await toggle.getAttribute("data-state"); + const expected = initial === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expected); + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", initial!); + }); + + test("Should edit webhook and cycle through auth types", async ({ dashboardAsOwner: page }) => { + // Basic Auth + await openWebhookEdit(page); + await selectWebhookAuth(page, "Basic Auth"); + await page.getByTestId("webhook-basic-username").fill("admin"); + await page.getByTestId("webhook-basic-password").fill("password"); + await page.getByTestId("webhook-save").click(); + + // Bearer Token + await openWebhookEdit(page); + await selectWebhookAuth(page, "Bearer Token"); + await page.getByTestId("webhook-bearer-token").fill("my-bearer-token"); + await page.getByTestId("webhook-save").click(); + + // Custom Auth + await openWebhookEdit(page); + await selectWebhookAuth(page, "Custom Authentication"); + await page.getByTestId("webhook-custom-auth-name").fill("X-API-Key"); + await page.getByTestId("webhook-custom-auth-value").fill("secret-api-key"); + await page.getByTestId("webhook-save").click(); + }); + + test("Should manage custom headers", async ({ dashboardAsOwner: page }) => { + await page.reload(); + // Ensure webhook exists (previous test may have failed) + if (await page.getByTestId("webhook-connect").isVisible().catch(() => false)) { + await page.getByTestId("webhook-connect").click(); + await page.getByTestId("webhook-url-input").fill("https://webhook.example/test"); + await page.getByTestId("webhook-continue").click(); + await page.getByTestId("webhook-save").click(); + await expect(page.getByTestId("webhook-actions")).toBeVisible(); + } + await openWebhookEdit(page); + await page.getByTestId("webhook-tab-headers").click({ force: true }); + + // Remove existing headers + const removeButtons = page.getByTestId("webhook-header-remove"); + const count = await removeButtons.count(); + for (let i = 0; i < count; i++) { + await page.getByTestId("webhook-header-remove").first().click({ force: true }); + } + + // Add new header + await page.getByTestId("webhook-add-header").click({ force: true }); + await page.getByTestId("webhook-header-name").last().fill("X-Custom-Header"); + await page.getByTestId("webhook-header-value").last().fill("my-custom-value"); + await page.getByTestId("webhook-save").click(); + + // Verify persistence + await page.reload(); + await openWebhookEdit(page); + await page.getByTestId("webhook-tab-headers").click({ force: true }); + // Verify the custom header exists (there may be auth headers with the same testid) + const headerNames = page.getByTestId("webhook-header-name"); + const headerCount = await headerNames.count(); + let found = false; + for (let i = 0; i < headerCount; i++) { + if ((await headerNames.nth(i).inputValue()) === "X-Custom-Header") { + found = true; + break; + } + } + expect(found).toBe(true); + await page.getByRole("button", { name: "Cancel" }).click({ force: true }); + }); + + test("Should delete the webhook", async ({ dashboardAsOwner: page }) => { + await page.reload(); + await page.getByTestId("webhook-actions").click({ force: true }); + await page.getByTestId("webhook-delete").click({ force: true }); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await expect(page.getByTestId("webhook-connect")).toBeVisible(); + }); +}); + +async function openWebhookEdit(page: import("@playwright/test").Page) { + await expect(page.getByTestId("webhook-actions")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("webhook-actions").click({ force: true }); + await expect(page.getByTestId("webhook-edit")).toBeVisible({ timeout: 5_000 }); + await page.getByTestId("webhook-edit").click({ force: true }); +} + +async function selectWebhookAuth(page: import("@playwright/test").Page, label: string) { + await page.getByTestId("webhook-auth-type").click(); + await page.locator("[cmdk-list]").getByText(label).click(); +} + +async function ensureWebhookDisconnected(page: import("@playwright/test").Page) { + await expect( + page.getByTestId("webhook-connect").or(page.getByTestId("webhook-actions")), + ).toBeVisible(); + if (await page.getByTestId("webhook-actions").isVisible().catch(() => false)) { + await page.getByTestId("webhook-actions").click({ force: true }); + await page.getByTestId("webhook-delete").click({ force: true }); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await expect(page.getByTestId("webhook-connect")).toBeVisible(); + } +} diff --git a/e2e/tests/settings-permissions.spec.ts b/e2e/tests/settings-permissions.spec.ts new file mode 100644 index 0000000..f22f0fa --- /dev/null +++ b/e2e/tests/settings-permissions.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; + +test.describe.serial("Settings - Permissions @settings", () => { + test("Should toggle restrict dashboard for regular users", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/settings?tab=permissions"); + + const toggle = page.getByTestId("restrict-regular-users"); + await expect(toggle).toBeVisible({ timeout: 15_000 }); + const initialState = await toggle.getAttribute("data-state"); + const expectedState = initialState === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expectedState); + + await page.getByTestId("save-permissions-settings").click(); + await expect(page.getByText("updated successfully").first()).toBeVisible(); + + // Verify persistence — wait for settings API to load after reload + await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().includes("/api/accounts") && + resp.request().method() === "GET", + ), + page.reload(), + ]); + await expect(page.getByTestId("restrict-regular-users")).toHaveAttribute( + "data-state", + expectedState, + { timeout: 15_000 }, + ); + + // Toggle back to restore original state + await page.getByTestId("restrict-regular-users").click(); + await page.getByTestId("save-permissions-settings").click(); + await expect(page.getByText("updated successfully").first()).toBeVisible(); + }); +}); diff --git a/e2e/tests/setup-keys.spec.ts b/e2e/tests/setup-keys.spec.ts new file mode 100644 index 0000000..aa48831 --- /dev/null +++ b/e2e/tests/setup-keys.spec.ts @@ -0,0 +1,161 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api"; + +let setupKeys: string[] = []; +let setupKeysCreatedGroups: string[] = []; + +test.describe.serial("Setup Keys @setup-keys", () => { + test("Should create a simple setup key", async ({ dashboardAsOwner: page }) => { + // Clean up leftovers from previous runs + await deleteSetupKeysByPrefix(page, "setup-key"); + await deleteGroupsByPrefix(page, "sk-group-"); + await navigateTo(page, "/setup-keys"); + const name = generateRandomName("setup-key"); + await createSetupKey(page, { name }); + setupKeys.push(name); + }); + + test("Should create a reusable setup key", async ({ dashboardAsOwner: page }) => { + const name = generateRandomName("setup-key"); + await createSetupKey(page, { name, reusable: true }); + setupKeys.push(name); + }); + + test("Should create a setup key with all options", async ({ dashboardAsOwner: page }) => { + const group1 = generateRandomName("sk-group-"); + const group2 = generateRandomName("sk-group-"); + setupKeysCreatedGroups.push(group1, group2); + + const name = generateRandomName("setup-key"); + await createSetupKey(page, { + name, + reusable: true, + usageLimit: "100", + expiration: "365", + ephemeral: true, + groups: [group1, group2], + }); + setupKeys.push(name); + }); + + test("Should revoke setup keys", async ({ dashboardAsOwner: page }) => { + for (const name of setupKeys) { + await revokeSetupKey(page, name); + } + }); + + test("Should delete setup keys", async ({ dashboardAsOwner: page }) => { + for (const name of setupKeys) { + await deleteSetupKey(page, name); + } + }); + + test("Should delete created groups", async ({ dashboardAsOwner: page }) => { + for (const prefix of setupKeysCreatedGroups) { + await deleteGroupsByPrefix(page, prefix); + } + setupKeysCreatedGroups = []; + }); +}); + +async function createSetupKey( + page: import("@playwright/test").Page, + opts: { + name: string; + reusable?: boolean; + usageLimit?: string; + expiration?: string; + ephemeral?: boolean; + groups?: string[]; + }, +) { + await page.getByTestId("open-create-setup-key").click(); + await page.getByTestId("setup-key-name").fill(opts.name); + + if (opts.reusable) { + await page.getByText("Make this key reusable").click(); + if (opts.usageLimit) { + await page.getByTestId("setup-key-usage-limit").fill(opts.usageLimit); + } + } + + if (opts.expiration) { + await page.getByTestId("setup-key-expire-in-days").fill(opts.expiration); + } + + if (opts.ephemeral) { + await page.getByText("Ephemeral Peers").click(); + } + + if (opts.groups && opts.groups.length > 0) { + await page.getByTestId("group-selector-dropdown").click(); + for (const group of opts.groups) { + const search = page.getByTestId("group-selector-dropdown-search"); + await expect(search).toBeVisible(); + await search.fill(group); + await search.press("Enter"); + } + await page.getByTestId("group-selector-dropdown-search").press("Escape"); + await expect( + page.getByTestId("group-selector-dropdown-search"), + ).not.toBeVisible(); + } + + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes("/api/setup-keys") && resp.request().method() === "GET", + ); + await page.getByTestId("create-setup-key").click(); + + const copyInput = page.getByTestId("setup-key-copy-input"); + const keyValue = await copyInput.getAttribute("data-testid-setup-key-value"); + expect(keyValue!.length).toBeGreaterThan(10); + await page.getByTestId("setup-key-close").click(); + + await expect(copyInput).not.toBeVisible(); + await responsePromise; + await expect(page.getByText(opts.name)).toBeVisible(); +} + +async function revokeSetupKey( + page: import("@playwright/test").Page, + name: string, +) { + // Row actions are now behind a dropdown menu. + await page + .locator("tr") + .filter({ hasText: name }) + .getByTestId("setup-key-actions") + .click({ force: true }); + await page + .locator('[data-testid="revoke-setup-key"]:not([data-disabled])') + .click({ force: true }); + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT", + { timeout: 10_000 }, + ); + await page.getByTestId("confirmation.confirm").click(); + await responsePromise; + await expect( + page + .locator("tr") + .filter({ hasText: name }) + .getByTestId("circle-icon-inactive"), + ).toBeVisible(); +} + +async function deleteSetupKey( + page: import("@playwright/test").Page, + name: string, +) { + // Row actions are now behind a dropdown menu. + await page + .locator("tr") + .filter({ hasText: name }) + .getByTestId("setup-key-actions") + .click({ force: true }); + await page.getByTestId("delete-setup-key").click({ force: true }); + await page.getByTestId("confirmation.confirm").click(); + await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible(); +} diff --git a/e2e/tests/team-service-users.spec.ts b/e2e/tests/team-service-users.spec.ts new file mode 100644 index 0000000..a2c422e --- /dev/null +++ b/e2e/tests/team-service-users.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; + +let regularUser = ""; +let adminServiceUser = ""; + +test.describe.serial("Team - Service Users @team", () => { + test("Should create service users and verify roles", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/team/service-users"); + + regularUser = generateRandomName("svc-user-"); + adminServiceUser = generateRandomName("svc-admin-"); + + await createServiceUser(page, regularUser, "User"); + await createServiceUser(page, adminServiceUser, "Admin"); + + await checkServiceUserRow(page, regularUser, "User"); + await checkServiceUserRow(page, adminServiceUser, "Admin"); + }); + + test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => { + await page.locator("tr").getByText(regularUser).click(); + await changeRoleTo(page, "Admin"); + await page.getByTestId("save-changes").click(); + + // Create and delete access token + const tokenName = generateRandomName("tkn_"); + await page.getByTestId("access-token-open-modal").click(); + await page.getByTestId("access-token-name").fill(tokenName); + await page.getByTestId("access-token-expires-in").fill("30"); + await page.getByTestId("create-access-token").click(); + await expect(page.getByTestId("access-token-copy-close")).toBeVisible(); + await page.getByTestId("access-token-copy-close").click(); + + const tokenRow = page.locator("tr").filter({ hasText: tokenName }); + await tokenRow.getByTestId("access-token-delete").click(); + await page.getByTestId("confirmation.confirm").click(); + await expect(tokenRow).not.toBeVisible(); + + await page.getByText("Service Users").first().click(); + }); + + test("Should update admin user role and verify all changes persisted", async ({ + dashboardAsOwner: page, + }) => { + await page.locator("tr").getByText(adminServiceUser).click(); + await changeRoleTo(page, "User"); + const saveResponse = page.waitForResponse( + (resp) => resp.url().includes("/api/users/") && resp.request().method() === "PUT", + { timeout: 30_000 }, + ); + await page.getByTestId("save-changes").click(); + await saveResponse; + + await page.getByText("Service Users").first().click(); + await checkServiceUserRow(page, regularUser, "Admin"); + await checkServiceUserRow(page, adminServiceUser, "User"); + + // Single reload to verify all changes persisted + await page.reload(); + await checkServiceUserRow(page, regularUser, "Admin"); + await checkServiceUserRow(page, adminServiceUser, "User"); + }); + + test("Should delete service users", async ({ dashboardAsOwner: page }) => { + for (const name of [regularUser, adminServiceUser]) { + const row = page.locator("tr").filter({ hasText: name }); + // Row actions are now behind a dropdown menu; open it, then delete. + await row.getByTestId("user-actions").click({ force: true }); + await page.getByTestId("delete-user").click({ force: true }); + await page.getByTestId("confirmation.confirm").click(); + await expect(row).not.toBeVisible(); + } + }); +}); + +async function createServiceUser( + page: import("@playwright/test").Page, + name: string, + role: string, +) { + await page.getByTestId("open-service-user-modal").click(); + await expect(page.getByTestId("service-user-name")).toBeVisible({ timeout: 5_000 }); + await page.getByTestId("service-user-name").fill(name); + await page.getByTestId("user-role-selector").click({ force: true }); + await page + .getByTestId("user-role-selector-item") + .getByText(role, { exact: true }) + .click({ force: true }); + await page.getByTestId("create-service-user").click(); + // Wait for modal to close + await expect(page.getByTestId("service-user-name")).not.toBeVisible({ timeout: 5_000 }); +} + +async function checkServiceUserRow( + page: import("@playwright/test").Page, + name: string, + role: string, +) { + const row = page.locator("tr").filter({ hasText: name }); + await expect(row).toBeVisible({ timeout: 10_000 }); + await expect(row.getByText(role, { exact: true }).first()).toBeVisible({ timeout: 10_000 }); +} + +async function changeRoleTo( + page: import("@playwright/test").Page, + role: string, +) { + await page.getByTestId("user-role-selector").click(); + await page + .getByTestId("user-role-selector-item") + .getByText(role, { exact: true }) + .click(); +} diff --git a/e2e/tests/team-users-approval-and-billing.spec.ts b/e2e/tests/team-users-approval-and-billing.spec.ts new file mode 100644 index 0000000..7d03d44 --- /dev/null +++ b/e2e/tests/team-users-approval-and-billing.spec.ts @@ -0,0 +1,150 @@ +import { expect, test } from "../helpers/fixtures"; +import { loginToApp, navigateTo } from "../helpers/auth"; +import { deleteUserByEmail } from "../helpers/api"; + +test.setTimeout(60_000); + +test.describe.serial("User Approval & Billing Admin @team", () => { + // ── User Approval ──────────────────────────────────────────────────── + + test("Should show approval pending for the second user", async ({ + browser, + dashboardAsOwner: ownerPage, + }) => { + // Clean up user from previous runs so approval flow starts fresh + await deleteUserByEmail(ownerPage, "user@localhost.test"); + + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/user.json", + }); + const page = await context.newPage(); + await loginToApp(page, "user"); + await expect(page.getByText("User Approval Pending")).toBeVisible(); + await context.close(); + }); + + test("Should approve the pending user", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/team/users"); + + const pendingRow = page.locator("tr").filter({ hasText: "Pending" }); + await expect(pendingRow).toBeVisible(); + await pendingRow.getByRole("button", { name: "Approve" }).click(); + await expect(pendingRow).not.toBeVisible(); + }); + + test("Should delete the approved user", async ({ + dashboardAsOwner: page, + }) => { + const userRow = page + .locator("tr") + .filter({ hasText: "user@localhost.test" }); + await expect(userRow).toBeVisible(); + // Row actions are now behind a dropdown menu. + await userRow.getByTestId("user-actions").click({ force: true }); + await page.getByTestId("delete-user").click({ force: true }); + await page.getByTestId("confirmation.confirm").click(); + await expect(userRow).not.toBeVisible(); + }); + + // ── Billing Admin ──────────────────────────────────────────────────── + + test("Should login as second user to trigger registration", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/user.json", + }); + const page = await context.newPage(); + await loginToApp(page, "user"); + await context.close(); + }); + + test("Should approve user and assign Billing Admin role", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/team/users"); + + const pendingRow = page.locator("tr").filter({ hasText: "Pending" }); + if (await pendingRow.isVisible({ timeout: 5_000 }).catch(() => false)) { + await pendingRow.getByRole("button", { name: "Approve" }).click(); + await expect(pendingRow).not.toBeVisible(); + } + + const userRow = page + .locator("tr") + .filter({ hasText: "user@localhost.test" }); + await expect(userRow).toBeVisible(); + await userRow.getByTestId("user-name-cell").click(); + await expect( + page.getByTestId("breadcrumb-item").filter({ hasText: /^user/i }), + ).toBeVisible(); + + await expect(page.getByTestId("user-role-selector")).toBeEnabled({ + timeout: 15_000, + }); + const currentRole = await page + .getByTestId("user-role-selector") + .textContent(); + if (!currentRole?.includes("Billing Admin")) { + await page.getByTestId("user-role-selector").click(); + await page + .getByTestId("user-role-selector-item") + .filter({ hasText: "Billing Admin" }) + .click(); + await page.getByTestId("save-changes").click(); + } + }); + + test("Should show Plans & Billing and Invoices for the Billing Admin", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/user.json", + }); + const page = await context.newPage(); + await loginToApp(page, "user"); + + await expect(page.getByTestId("user-dropdown")).toBeVisible({ + timeout: 15_000, + }); + await page.getByTestId("user-dropdown").click({ force: true }); + await page.getByText("Plans & Billing").click(); + + await expect( + page.getByTestId("settings-tab-plans-and-billing"), + ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("settings-tab-invoices")).toBeVisible(); + await expect( + page.getByTestId("settings-content-plans-and-billing"), + ).toBeVisible(); + + await page.getByTestId("settings-tab-invoices").click(); + await expect(page.getByTestId("settings-content-invoices")).toBeVisible(); + + await expect( + page.getByTestId("settings-tab-authentication"), + ).not.toBeVisible(); + await expect( + page.getByTestId("settings-tab-permissions"), + ).not.toBeVisible(); + await expect(page.getByTestId("settings-tab-clients")).not.toBeVisible(); + + await context.close(); + }); + + test("Should delete the second user", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/team/users"); + + const userRow = page + .locator("tr") + .filter({ hasText: "user@localhost.test" }); + await expect(userRow).toBeVisible(); + // Row actions are now behind a dropdown menu. + await userRow.getByTestId("user-actions").click({ force: true }); + await page.getByTestId("delete-user").click({ force: true }); + await page.getByTestId("confirmation.confirm").click(); + await expect(userRow).not.toBeVisible(); + }); +}); diff --git a/e2e/tests/team-users.spec.ts b/e2e/tests/team-users.spec.ts new file mode 100644 index 0000000..24646c8 --- /dev/null +++ b/e2e/tests/team-users.spec.ts @@ -0,0 +1,105 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let createdGroupName = ""; + +test.describe.serial("Team - Users @team", () => { + test('Should show the owner with "You" badge and "Owner" role', async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/team/users"); + + const ownerRow = page + .getByTestId("user-name-cell") + .filter({ hasText: "You" }) + .locator("xpath=ancestor::tr"); + await expect(ownerRow).toBeVisible(); + await expect(ownerRow.getByText("Owner", { exact: true })).toBeVisible(); + }); + + test("Should open the user detail page with Peers and Access Tokens tabs", async ({ + dashboardAsOwner: page, + }) => { + await openOwnerDetailPage(page); + + await expect(page.getByTestId("user-tab-peers")).toBeVisible(); + await expect(page.getByTestId("user-tab-access-tokens")).toBeVisible(); + + await page.getByTestId("user-tab-peers").click(); + await expect(page.getByText("View all peers registered by this user.")).toBeVisible(); + + await page.getByTestId("user-tab-access-tokens").click(); + await expect(page.getByText("Access tokens give access to NetBird API.")).toBeVisible(); + }); + + test("Should add an auto-assigned group, save, and verify persistence", async ({ + dashboardAsOwner: page, + }) => { + // Go back to users list via breadcrumb + await page.getByTestId("breadcrumb-item").filter({ hasText: "Users" }).click(); + await openOwnerDetailPage(page); + + const name = generateRandomName("user-group-"); + createdGroupName = name; + + await page.getByTestId("user-group-selector").click(); + const search = page.getByTestId("user-group-selector-search"); + await expect(search).toBeVisible(); + await search.fill(name); + await search.press("Enter"); + await expect( + page.getByTestId("user-group-selector").getByText(name), + ).toBeVisible(); + await search.press("Escape"); + + const saveResponse = page.waitForResponse( + (resp) => resp.url().includes("/api/users/") && resp.request().method() === "PUT", + { timeout: 30_000 }, + ); + await page.getByTestId("save-changes").click(); + await saveResponse; + await expect( + page.getByTestId("user-group-selector").getByText(name), + ).toBeVisible(); + + await page.reload(); + await expect( + page.getByTestId("user-group-selector").getByText(name), + ).toBeVisible(); + }); + + test("Should remove the auto-assigned group, save, and verify removal", async ({ + dashboardAsOwner: page, + }) => { + // Already on user detail page from previous test (after reload) + await page + .getByTestId("user-group-selector") + .getByTestId("group-badge") + .filter({ hasText: createdGroupName }) + .click(); + + await page.getByTestId("save-changes").click(); + await expect( + page.getByTestId("user-group-selector").getByText(createdGroupName), + ).not.toBeVisible(); + + await page.reload(); + await expect( + page.getByTestId("user-group-selector").getByText(createdGroupName), + ).not.toBeVisible(); + }); + + test("Should delete the created group", async ({ dashboardAsOwner: page }) => { + await deleteGroupsByPrefix(page, createdGroupName); + }); +}); + +async function openOwnerDetailPage(page: import("@playwright/test").Page) { + await page.getByTestId("user-name-cell").filter({ hasText: "You" }).click(); + await expect( + page.getByTestId("breadcrumb-item").filter({ hasText: "Users" }), + ).toBeVisible(); + await expect(page.getByText("Auto-assigned groups")).toBeVisible(); +} diff --git a/package.json b/package.json index 100c152..f1e932b 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,15 @@ "dev": "next dev -p 3000", "turbo": "next dev -p 3000 --turbo", "build": "next build", + "postbuild": "node postbuild.js", "start": "next start", "lint": "next lint", - "cypress:open": "cypress open" + "test:setup": "cd ./e2e/environment && sh create-test-env.sh", + "test:clean": "cd ./e2e/environment && sh clean-test-env.sh", + "test:dev": "cross-env APP_ENV=test next dev -p 1337", + "test": "npx playwright test --config=e2e/playwright.config.ts", + "test:ui": "npx playwright test --config=e2e/playwright.config.ts --ui", + "test:ci": "cross-env APP_ENV=test next build && npx playwright test --config=e2e/playwright.config.ts" }, "dependencies": { "@axa-fr/react-oidc": "^7.26.3", @@ -40,7 +46,7 @@ "@tanstack/react-table": "^8.10.7", "@types/crypto-js": "^4.2.2", "@types/d3": "^7.4.3", - "@types/lodash": "^4.14.200", + "@types/lodash": "4.17.24", "@types/node": "20.10.6", "@types/react": "^19", "@types/react-dom": "^19", @@ -55,6 +61,7 @@ "classnames": "^2.5.1", "clsx": "^2.0.0", "cmdk": "^1.1.1", + "cross-env": "^7.0.3", "crypto-js": "^4.2.0", "d3": "^7.9.0", "date-fns": "^2.30.0", @@ -63,16 +70,18 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-simple-import-sort": "^10.0.0", "framer-motion": "^12.29.2", - "ip-address": "^10.1.0", + "ip-address": "^10.2.0", "ip-cidr": "^3.1.0", - "js-cookie": "^3.0.5", - "lodash": "^4.17.23", + "js-cookie": "^3.0.7", + "lodash": "4.18.1", "lucide-react": "^0.566.0", "next": "16.1.7", "next-intl": "^4.13.0", "next-themes": "^0.2.1", "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", @@ -97,6 +106,7 @@ "@faker-js/faker": "^9.5.1", "@types/chroma-js": "^3.1.1", "@types/js-cookie": "^3.0.6", + "@playwright/test": "^1.52.0", "eslint": "^9.39.1", "eslint-config-next": "^16.1.6", "postcss": "^8", diff --git a/postbuild.js b/postbuild.js new file mode 100644 index 0000000..036348a --- /dev/null +++ b/postbuild.js @@ -0,0 +1,96 @@ +const { resolve, join } = require("path"); +const { createHash } = require("crypto"); +const { + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, +} = require("fs"); + +process.env.NODE_ENV = "production"; +const PLACEHOLDER = "NB_INLINE_SCRIPT_PLACEHOLDER"; +console.log("Starting post-build script to extract inline scripts..."); + +// Function to find HTML files recursively +function findHtmlFiles(dir) { + const files = []; + const entries = readdirSync(dir); + + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + files.push(...findHtmlFiles(fullPath)); + } else if (entry.endsWith(".html")) { + files.push(fullPath); + } + } + + return files; +} + +// For Next.js export output, the files are in the 'out' directory +const baseDir = resolve("out"); +const htmlFiles = findHtmlFiles(baseDir); + +console.log(`Found ${htmlFiles.length} .html files to process`); + +// Ensure assets directory exists +const assetsDir = `${baseDir}/assets`; +mkdirSync(assetsDir, { recursive: true }); + +htmlFiles.forEach((file) => { + // Read file contents + const contents = readFileSync(file, "utf8"); + const scripts = []; + + // Extract inline scripts + const newFile = contents.replace( + /]*src)([^>]*)>(.+?)<\/script>/gs, + (match, attributes, scriptContent) => { + // Skip if script has src attribute (external script) + if (attributes.includes("src=")) { + return match; + } + + const addPlaceholderString = scripts.length === 0; + const cleanedScript = scriptContent.trim(); + + if (cleanedScript) { + scripts.push( + `${cleanedScript}${cleanedScript.endsWith(";") ? "" : ";"}`, + ); + } + + return addPlaceholderString ? PLACEHOLDER : ""; + }, + ); + + // Early exit if no inline scripts found + if (!scripts.length) { + console.log(`No inline scripts found`); + return; + } + + // Combine scripts and create hash + const chunk = scripts.join("\n"); + const hash = createHash("md5").update(chunk).digest("hex").slice(0, 8); + const chunkFileName = `chunk.${hash}.js`; + const chunkPath = `${assetsDir}/${chunkFileName}`; + + // Write the chunk file + writeFileSync(chunkPath, chunk, "utf8"); + + // Replace placeholder string with script tag + const updatedFile = newFile.replace( + PLACEHOLDER, + ``, + ); + + // Write updated HTML file + writeFileSync(file, updatedFile, "utf8"); +}); + +console.log("Post-build script completed successfully!"); diff --git a/src/app/(dashboard)/(cloud)/customers/layout.tsx b/src/app/(dashboard)/(cloud)/customers/layout.tsx new file mode 100644 index 0000000..db3d04d --- /dev/null +++ b/src/app/(dashboard)/(cloud)/customers/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Customers - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/customers/page.tsx b/src/app/(dashboard)/(cloud)/customers/page.tsx new file mode 100644 index 0000000..3aaf37a --- /dev/null +++ b/src/app/(dashboard)/(cloud)/customers/page.tsx @@ -0,0 +1,69 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { usePortalElement } from "@hooks/usePortalElement"; +import useFetchApi from "@utils/api"; +import React, { Suspense } from "react"; +import MSPIcon from "@/assets/icons/MSPIcon"; +import { CustomersProvider } from "@/cloud/distributor/contexts/CustomersProvider"; +import DistributorCustomersTable from "@/cloud/distributor/table/DistributorCustomersTable"; +import { DistributorDocsLink } from "@/cloud/distributor/DistributorDocsLink"; +import { useDistributor } from "@/cloud/distributor/contexts/DistributorProvider"; +import { DistributorCustomer } from "@/cloud/distributor/interfaces/Distributor"; +import PageContainer from "@/layouts/PageContainer"; +import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePermissions } from "@/contexts/PermissionsProvider"; + +export default function CustomersPage() { + const { isDistributorInfoLoading } = useDistributor(); + if (isDistributorInfoLoading) return ; + return ; +} + +const CustomersPageContent = () => { + const { permission } = usePermissions(); + const { data: customers, isLoading } = useFetchApi( + "/integrations/msp/reseller/msps", + ); + const { ref: headingRef, portalTarget } = + usePortalElement(); + + return ( + +
+ + } + /> + +

Customers

+ + Use this view to manage customer accounts and their plans. + + + + in our documentation. + +
+ + }> + + + + + +
+ ); +}; diff --git a/src/app/(dashboard)/(cloud)/integrations/layout.tsx b/src/app/(dashboard)/(cloud)/integrations/layout.tsx new file mode 100644 index 0000000..a41667c --- /dev/null +++ b/src/app/(dashboard)/(cloud)/integrations/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Integrations - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/integrations/page.tsx b/src/app/(dashboard)/(cloud)/integrations/page.tsx new file mode 100644 index 0000000..d33e956 --- /dev/null +++ b/src/app/(dashboard)/(cloud)/integrations/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { VerticalTabs } from "@components/VerticalTabs"; +import { + FileText, + FingerprintIcon, + KeyRoundIcon, + ShieldCheckIcon, +} from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import React, { useState } from "react"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import PageContainer from "@/layouts/PageContainer"; +import { useAccount } from "@/modules/account/useAccount"; +import EDRTab from "@/modules/integrations/edr/EDRTab"; +import EventStreamingTab from "@/modules/integrations/event-streaming/EventStreamingTab"; +import IdentityProviderTab from "@/modules/integrations/idp-sync/IdentityProviderTab"; +import SSOTab from "@/modules/integrations/sso/SSOTab"; +import { isNetBirdCloud } from "@utils/netbird"; + +export default function Integrations() { + const searchParams = useSearchParams(); + const currentTab = searchParams.get("tab"); + const [tab, setTab] = useState(currentTab || "identity-provider"); + const account = useAccount(); + const { permission } = usePermissions(); + + return ( + + + + + + Identity Provider Sync + + + {isNetBirdCloud() && ( + + + Single Sign-On + + )} + + + + Event Streaming + + + + MDM & EDR + + + +
+ + + + {account && } +
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/(cloud)/msp/page.tsx b/src/app/(dashboard)/(cloud)/msp/page.tsx new file mode 100644 index 0000000..b5f4ec4 --- /dev/null +++ b/src/app/(dashboard)/(cloud)/msp/page.tsx @@ -0,0 +1,150 @@ +"use client"; + +import Button from "@components/Button"; +import { Callout } from "@components/Callout"; +import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal"; +import { notify } from "@components/Notification"; +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import useRedirect from "@hooks/useRedirect"; +import { useApiCall } from "@utils/api"; +import { LockIcon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import React, { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import NetBirdIcon from "@/assets/icons/NetBirdIcon"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; + +export default function JoinMspPage() { + const searchParams = useSearchParams(); + const inviteCode = searchParams.get("invite"); + + const { mutate } = useSWRConfig(); + const router = useRouter(); + const { isMspInfoLoading, mspInfo, isActive, isAccountWithMSPParent } = + useMSP(); + const [open, setOpen] = useState(true); + const { isOwner } = useLoggedInUser(); + const [isAccepting, setIsAccepting] = useState(false); + const [calledOnce, setCalledOnce] = useState(false); + const isMSPAccount = !!mspInfo && isActive; + + const mspRequest = useApiCall("/integrations/msp", true, { + ignoreGlobalParams: true, + }); + + const declineButtonText = useMemo(() => { + if (isMSPAccount && !calledOnce) return "Go to Tenants"; + if (isOwner) return "Decline"; + return "Go to Peers"; + }, [isMSPAccount, calledOnce, isOwner]); + + if (isAccountWithMSPParent || !inviteCode) return ; + + const acceptInvitation = async () => { + if (isAccepting) return; + setCalledOnce(true); + setIsAccepting(true); + const promise = mspRequest + .post({ + invite: inviteCode, + }) + .then(() => { + mutate("/integrations/msp"); + mutate("/integrations/msp/tenants"); + router.push("/tenants"); + }) + .finally(() => setIsAccepting(false)); + + notify({ + title: `NetBird Managed Service Provider`, + description: `Successfully joined as an Managed Service Provider`, + loadingMessage: `Processing your invitation...`, + promise, + }); + return promise; + }; + + const redirectTo = () => { + if (isMSPAccount) { + router.push("/tenants"); + } else { + router.push("/peers"); + } + }; + + const isDisabled = !isOwner || isMspInfoLoading || isMSPAccount; + + return ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + > + +
+
+ +
+ +
+ NetBird invites you to join as an Managed Service Provider (MSP) +
+
+ You will get access to the NetBird MSP portal where you can manage + multiple customers and their networks from a single place. +
+ {!isOwner && !isMSPAccount && ( + + } + className={"text-xs mt-3"} + > + Only the owner of the account can accept this invitation. Please + contact the owner of the account to accept the invitation. + + )} + {isMSPAccount && !calledOnce && ( + + The invitation has already been accepted + + )} +
+ + + + + + +
+
+ ); +} + +const Redirect = () => { + useRedirect("/peers"); + return ; +}; diff --git a/src/app/(dashboard)/(cloud)/plans/cancel/layout.tsx b/src/app/(dashboard)/(cloud)/plans/cancel/layout.tsx new file mode 100644 index 0000000..708ad1e --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/cancel/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Plans - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/plans/cancel/page.tsx b/src/app/(dashboard)/(cloud)/plans/cancel/page.tsx new file mode 100644 index 0000000..22da5ed --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/cancel/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { useRedirect } from "@hooks/useRedirect"; + +export default function PlanCancel() { + useRedirect("/settings?tab=plans-and-billing"); + return ; +} diff --git a/src/app/(dashboard)/(cloud)/plans/layout.tsx b/src/app/(dashboard)/(cloud)/plans/layout.tsx new file mode 100644 index 0000000..708ad1e --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Plans - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/plans/page.tsx b/src/app/(dashboard)/(cloud)/plans/page.tsx new file mode 100644 index 0000000..2c8f81d --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { useRedirect } from "@hooks/useRedirect"; +import React from "react"; + +export default function PlanSuccess() { + useRedirect("/settings?tab=plans-and-billing"); + return ; +} diff --git a/src/app/(dashboard)/(cloud)/plans/success/layout.tsx b/src/app/(dashboard)/(cloud)/plans/success/layout.tsx new file mode 100644 index 0000000..708ad1e --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/success/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Plans - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/plans/success/page.tsx b/src/app/(dashboard)/(cloud)/plans/success/page.tsx new file mode 100644 index 0000000..41311dc --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/success/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { useRedirect } from "@hooks/useRedirect"; +import React from "react"; + +export default function PlanSuccess() { + useRedirect("/settings?tab=plans-and-billing&success=true"); + return ; +} diff --git a/src/app/(dashboard)/(cloud)/tenants/layout.tsx b/src/app/(dashboard)/(cloud)/tenants/layout.tsx new file mode 100644 index 0000000..8ad72fe --- /dev/null +++ b/src/app/(dashboard)/(cloud)/tenants/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Tenants - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/tenants/page.tsx b/src/app/(dashboard)/(cloud)/tenants/page.tsx new file mode 100644 index 0000000..6c8e0ed --- /dev/null +++ b/src/app/(dashboard)/(cloud)/tenants/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; +import useRedirect from "@hooks/useRedirect"; +import useFetchApi from "@utils/api"; +import React, { Suspense, useMemo } from "react"; +import MSPIcon from "@/assets/icons/MSPIcon"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { TenantsProvider } from "@/cloud/msp/contexts/TenantsProvider"; +import { Tenant } from "@/cloud/msp/interfaces/Tenant"; +import { MSPTenantDocsLink } from "@/cloud/msp/MSPTenantDocsLink"; +import MSPTenantsTable from "@/cloud/msp/MSPTenantsTable"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { User } from "@/interfaces/User"; +import PageContainer from "@/layouts/PageContainer"; + +export default function TenantsPage() { + const { isActive, isMSPInMSPContext, isMspInfoLoading } = useMSP(); + const { isOwnerOrAdmin } = useLoggedInUser(); + + const show = useMemo(() => { + if (!isActive) return false; + return isMSPInMSPContext && isOwnerOrAdmin; + }, [isActive, isMSPInMSPContext, isOwnerOrAdmin]); + + if (isMspInfoLoading) return ; + if (!show) return ; + return ; +} + +const Redirect = () => { + useRedirect("/peers"); + return ; +}; + +const TenantsPageContent = () => { + const { permission } = usePermissions(); + const { data: tenants, isLoading } = useFetchApi( + "/integrations/msp/tenants", + ); + const { ref: headingRef, portalTarget } = + usePortalElement(); + + useFetchApi("/users", true); + + return ( + +
+ + } + /> + +

Tenants

+ + A list of all tenants and their subscription details. Use this view to + manage accounts, plans and permissions. + + + + in our documentation. + +
+ + }> + + + + + +
+ ); +}; diff --git a/src/app/(dashboard)/control-center/page.tsx b/src/app/(dashboard)/control-center/page.tsx index d4aa559..19ca535 100644 --- a/src/app/(dashboard)/control-center/page.tsx +++ b/src/app/(dashboard)/control-center/page.tsx @@ -4,10 +4,7 @@ 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 { SelectDropdown, SelectOption } from "@components/select/SelectDropdown"; import SquareIcon from "@components/SquareIcon"; import GetStartedTest from "@components/ui/GetStartedTest"; import { SmallBadge } from "@components/ui/SmallBadge"; @@ -22,16 +19,10 @@ import { ReactFlowProvider, useEdgesState, useNodesState, - useReactFlow, + useReactFlow } from "@xyflow/react"; import { forEach, orderBy, sortBy } from "lodash"; -import { - ArrowLeftIcon, - ExternalLinkIcon, - LayoutGridIcon, - MessageSquareShareIcon, - NetworkIcon, -} from "lucide-react"; +import { ArrowLeftIcon, ExternalLinkIcon, LayoutGridIcon, 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"; @@ -53,13 +44,13 @@ import { EDGE_TYPES } from "@/modules/control-center/utils/edges"; import { getFirstGroup, getPolicyProtocolAndPortText, - getResourcePolicyByGroups, + getResourcePolicyByGroups } from "@/modules/control-center/utils/helpers"; import { applyD3ForceLayout, applyD3HierarchicalLayout, DEFAULT_MAX_ZOOM, - DEFAULT_MIN_ZOOM, + DEFAULT_MIN_ZOOM } from "@/modules/control-center/utils/layouts"; import { NODE_TYPES } from "@/modules/control-center/utils/nodes"; diff --git a/src/app/(dashboard)/dns/settings/page.tsx b/src/app/(dashboard)/dns/settings/page.tsx index 67ef12f..2d74ea7 100644 --- a/src/app/(dashboard)/dns/settings/page.tsx +++ b/src/app/(dashboard)/dns/settings/page.tsx @@ -122,13 +122,13 @@ const SettingDisabledManagementGroups = ({ }); }; - return ( +return (
{t("disabledManagementGroupHelp")} {t("saveChanges")} diff --git a/src/app/(dashboard)/events/audit/page.tsx b/src/app/(dashboard)/events/audit/page.tsx index 8a37486..4b8e034 100644 --- a/src/app/(dashboard)/events/audit/page.tsx +++ b/src/app/(dashboard)/events/audit/page.tsx @@ -14,6 +14,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; import { ActivityEvent } from "@/interfaces/ActivityEvent"; import PageContainer from "@/layouts/PageContainer"; import ActivityTable from "@/modules/activity/ActivityTable"; +import { EventStreamingCard } from "@/modules/integrations/event-streaming/EventStreamingCard"; export default function Activity() { const t = useTranslations("activity"); @@ -53,7 +54,8 @@ export default function Activity() {
- + + (() => { + if (dateFrom || dateTo) { + return { + from: dateFrom ? dayjs(dateFrom).toDate() : undefined, + to: dateTo ? dayjs(dateTo).toDate() : undefined, + }; + } + return undefined; + }); + + useEffect(() => { + localStorage.removeItem(`netbird-table-pagination${pathname}`); + localStorage.removeItem(`netbird-table-range${pathname}`); + localStorage.removeItem(`netbird-table-search${pathname}`); + + const keysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.includes(pathname)) { + keysToRemove.push(key); + } + } + + keysToRemove.forEach((key) => { + localStorage.removeItem(key); + }); + }, [pathname]); + + const baseUrl = `/events/network-traffic`; + const [apiUrl, setApiUrl] = useState(() => { + let url = `${baseUrl}?page=${currentPage}&page_size=${currentPageSize}`; + if (searchTerm) { + url += `&search=${encodeURIComponent(searchTerm)}`; + } + if (formattedDateFrom) { + url += `&start_date=${formattedDateFrom}`; + } + if (formattedDateTo) { + url += `&end_date=${formattedDateTo}`; + } + if (typeFilter) { + typeFilter.forEach((t) => { + url += `&type=${encodeURIComponent(t)}`; + }); + } + if (connectionTypeFilter) { + url += `&connection_type=${encodeURIComponent(connectionTypeFilter)}`; + } + if (directionFilter) { + protocolFilter.forEach((d) => { + url += `&direction=${encodeURIComponent(d)}`; + }); + } + if (formattedUserId) { + url += `&user_id=${formattedUserId}`; + } + if (protocolFilter) { + protocolFilter.forEach((protocol) => { + url += `&protocol=${encodeURIComponent(protocol)}`; + }); + } + return url; + }); + + const isTrafficEventsLocked = useIsFeatureLocked("TRAFFIC_EVENTS"); + + const { + data: events, + isLoading, + mutate, + } = useFetchApi>( + apiUrl, + false, + true, + !isTrafficEventsLocked, + ); + + // Fetch is suppressed while the feature lock resolves, refetch once unlocked. + useEffect(() => { + if (!isTrafficEventsLocked) { + mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTrafficEventsLocked]); + + const updateURL = useCallback( + (newParams: Record) => { + const params = new URLSearchParams(searchParams.toString()); + + const isFilterReset = Object.values(newParams).every( + (value) => value === "", + ); + + params.set("page", currentPage); + params.set("page_size", currentPageSize); + + if (isFilterReset) { + params.delete("search"); + params.delete("start_date"); + params.delete("end_date"); + params.delete("type"); + params.delete("connection_type"); + params.delete("direction"); + params.delete("protocol"); + params.delete("user_id"); + params.set("page", "1"); + params.set("page_size", "10"); + } else { + Object.entries(newParams).forEach(([key, value]) => { + if (value === "") { + if (key === "page_size" || key === "page") return; + params.delete(key); + } else if (Array.isArray(value)) { + value.forEach((v, i) => { + if (i === 0) { + params.set(key, v); + return; + } + params.append(key, v); + }); + } else { + params.set(key, value); + } + }); + } + + setApiUrl(`${baseUrl}?${params.toString()}`); + + if (typeof window !== "undefined") { + window.history.replaceState( + null, + "", + `${pathname}?${params.toString()}`, + ); + } + }, + [baseUrl, pathname, searchParams], + ); + + const handlePaginationChange = useCallback( + ({ pageIndex, pageSize }: { pageIndex: number; pageSize: number }) => { + updateURL({ + page: String(pageIndex + 1), + page_size: String(pageSize), + }); + }, + [updateURL], + ); + + const handleGlobalFilterChange = useCallback( + (value: string) => { + if (value === "" && currentPage !== "1") return; + updateURL({ + search: value, + page: "1", + }); + }, + [updateURL], + ); + + const handleDateFilterChange = useCallback( + (from?: Date, to?: Date) => { + const params: Record = {}; + + if (from) { + params.start_date = dayjs(from).toISOString(); + } else { + params.start_date = ""; + } + + if (to) { + params.end_date = dayjs(to).toISOString(); + } else { + params.end_date = ""; + } + + params.page = "1"; + updateURL(params); + }, + [updateURL], + ); + + const handleFilterChange = useCallback( + (filters: { + type?: string[]; + direction?: string[]; + protocol?: string[]; + }) => { + const params: Record = {}; + + if (filters.type && filters.type.length > 0) { + params.type = filters.type; + } else { + params.type = ""; + } + + if (filters.direction && filters.direction.length > 0) { + params.direction = filters.direction; + } else { + params.direction = ""; + } + + if (filters.protocol && filters.protocol.length > 0) { + params.protocol = filters.protocol; + } else { + params.protocol = ""; + } + + params.page = "1"; + updateURL(params); + }, + [updateURL], + ); + + const handleConnectionTypeFilterChange = useCallback( + (value: string) => { + updateURL({ + connection_type: value, + page: "1", + }); + }, + [updateURL], + ); + + const handleUserFilterChange = useCallback( + (selectedUserId: string) => { + updateURL({ + user_id: selectedUserId || "", + page: "1", + }); + }, + [updateURL], + ); + + const handleResetAllFilters = useCallback(() => { + const params = new URLSearchParams(); + params.set("page", "1"); + params.set("page_size", "10"); + + const newUrl = `${baseUrl}?${params.toString()}`; + setApiUrl(newUrl); + if (typeof window !== "undefined") { + window.history.replaceState(null, "", `${pathname}?${params.toString()}`); + } + }, [currentPageSize, baseUrl, pathname]); + + useEffect(() => { + if (dateFrom || dateTo) { + try { + const fromDate = dateFrom ? dayjs(dateFrom).toDate() : undefined; + const toDate = dateTo ? dayjs(dateTo).toDate() : undefined; + + const newDateRange = { + from: fromDate, + to: toDate, + }; + setDateRange(newDateRange); + } catch (error) {} + } else if (dateRange) { + setDateRange(undefined); + } + }, [dateFrom, dateTo]); + + const pagination = { + pageIndex: parseInt(currentPage, 10) - 1, + pageSize: parseInt(currentPageSize, 10), + }; + + const isEnabled = !!account?.settings?.extra?.network_traffic_logs_enabled; + + const tableFilters = { + type: typeFilter, + direction: directionFilter, + protocol: protocolFilter, + }; + + const trafficEvents = useMemo(() => { + // `data` may resolve to a non-array (e.g. an error body) on locked / + // self-hosted deployments; guard so `.map` never throws. + if (!Array.isArray(events?.data)) return undefined; + return events.data.map((event) => ({ + ...event, + id: event.flow_id, + })); + }, [events]); + + return ( + +
+ + } + /> + } + /> + + +

{`${events?.total_records ?? 0}`} Traffic Events

+ + + Traffic events is an experimental feature. Functionality and behavior + may evolve, including changes to how data is collected or reported. + + + + Learn more about{" "} + + Traffic Events + {" "} + in our documentation. + +
+ + +
+ +
+ + + + + + +
+
+ ); +} diff --git a/src/app/(dashboard)/group/page.tsx b/src/app/(dashboard)/group/page.tsx index 03fa07f..78e1239 100644 --- a/src/app/(dashboard)/group/page.tsx +++ b/src/app/(dashboard)/group/page.tsx @@ -184,6 +184,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { {group.name !== "All" && ( { {group.name !== "All" && ( { { @@ -234,6 +238,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { { { { {group.name !== "All" && ( ) { className={"pb-0 mb-0"} > - + {singularize(t("resources"), network?.resources?.length)} - + ) { /> {singularize(t("routingPeers"), network?.routing_peers_count)} - + - @@ -225,6 +228,7 @@ function NetworkActions() { openEditNetworkModal(network)} disabled={!permission.networks.update} + data-testid="rename-network" >
diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index 17e9deb..9469b88 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -50,6 +50,10 @@ import { toASCII } from "punycode"; import React, { useMemo, useState } from "react"; import Skeleton from "react-loading-skeleton"; import { useSWRConfig } from "swr"; +import { + TrafficEventsPeerTabContent, + TrafficEventsPeerTabTrigger, +} from "@/cloud/traffic-events/TrafficEventsPeerTabContent"; import RoundedFlag from "@/assets/countries/RoundedFlag"; import CircleIcon from "@/assets/icons/CircleIcon"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; @@ -367,7 +371,7 @@ const PeerOverviewTabs = () => { )} - {peer?.id && permission.peers.read && ( +{peer?.id && permission.peers.read && ( {t("tabAccessiblePeers")} @@ -384,12 +388,14 @@ const PeerOverviewTabs = () => { )} - {peer?.id && permission.peers.delete && ( +{peer?.id && permission.peers.delete && ( {t("tabRemoteJobs")} )} + + {permission.events.read && } @@ -402,7 +408,7 @@ const PeerOverviewTabs = () => { )} - {peer?.id && permission.peers.read && ( +{peer?.id && permission.peers.read && ( @@ -420,11 +426,17 @@ const PeerOverviewTabs = () => { )} - {peer.id && permission.peers.delete && ( +{peer.id && permission.peers.delete && ( )} + + {permission.events.read && ( + + + + )} ); }; diff --git a/src/app/(dashboard)/peers/page.tsx b/src/app/(dashboard)/peers/page.tsx index 502c410..a6c321e 100644 --- a/src/app/(dashboard)/peers/page.tsx +++ b/src/app/(dashboard)/peers/page.tsx @@ -2,4 +2,4 @@ import { redirect } from "next/navigation"; export default function PeersIndex() { redirect("/peers/users"); -} +} \ No newline at end of file diff --git a/src/app/(dashboard)/peers/servers/page.tsx b/src/app/(dashboard)/peers/servers/page.tsx index 91a75e1..1971db4 100644 --- a/src/app/(dashboard)/peers/servers/page.tsx +++ b/src/app/(dashboard)/peers/servers/page.tsx @@ -9,6 +9,9 @@ import { ExternalLinkIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import React, { lazy, Suspense, useMemo } from "react"; import PeerIcon from "@/assets/icons/PeerIcon"; +import { useBypassedPeers } from "@/cloud/edr/useBypass"; +import useDistributorRedirect from "@/cloud/distributor/useDistributorRedirect"; +import FullScreenLoading from "@components/ui/FullScreenLoading"; import PeersProvider, { usePeers } from "@/contexts/PeersProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useUsers } from "@/contexts/UsersProvider"; @@ -18,7 +21,9 @@ import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal"; const PeersTable = lazy(() => import("@/modules/peers/PeersTable")); export default function ServersPage() { - const { isRestricted } = usePermissions(); +const { isRestricted } = usePermissions(); + const { isLoading: isDistributorRedirecting } = useDistributorRedirect(); + if (isDistributorRedirecting) return ; return ( @@ -34,9 +39,10 @@ export default function ServersPage() { } function ServersView() { - const t = useTranslations("peers"); +const t = useTranslations("peers"); const { peers, isLoading: isPeersLoading } = usePeers(); const { users, isLoading: isUsersLoading } = useUsers(); + const { isBypassed } = useBypassedPeers(); const { ref: headingRef, portalTarget } = usePortalElement(); @@ -50,10 +56,10 @@ function ServersView() { return peers.map((peer) => ({ ...peer, user: users.find((u) => u.id === peer.user_id), + force_approved: peer.id ? isBypassed(peer.id) : false, })); - }, [peers, users]); - - return ( + }, [peers, users, isBypassed]); +return ( <>
diff --git a/src/app/(dashboard)/peers/users/page.tsx b/src/app/(dashboard)/peers/users/page.tsx index efc357b..51bc34c 100644 --- a/src/app/(dashboard)/peers/users/page.tsx +++ b/src/app/(dashboard)/peers/users/page.tsx @@ -9,6 +9,9 @@ import { ExternalLinkIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import React, { lazy, Suspense, useMemo } from "react"; import PeerIcon from "@/assets/icons/PeerIcon"; +import { useBypassedPeers } from "@/cloud/edr/useBypass"; +import useDistributorRedirect from "@/cloud/distributor/useDistributorRedirect"; +import FullScreenLoading from "@components/ui/FullScreenLoading"; import PeersProvider, { usePeers } from "@/contexts/PeersProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useUsers } from "@/contexts/UsersProvider"; @@ -18,7 +21,9 @@ import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal"; const PeersTable = lazy(() => import("@/modules/peers/PeersTable")); export default function UserDevicesPage() { - const { isRestricted } = usePermissions(); +const { isRestricted } = usePermissions(); + const { isLoading: isDistributorRedirecting } = useDistributorRedirect(); + if (isDistributorRedirecting) return ; return ( @@ -34,9 +39,10 @@ export default function UserDevicesPage() { } function UserDevicesView() { - const t = useTranslations("peers"); +const t = useTranslations("peers"); const { peers, isLoading: isPeersLoading } = usePeers(); const { users, isLoading: isUsersLoading } = useUsers(); + const { isBypassed } = useBypassedPeers(); const { ref: headingRef, portalTarget } = usePortalElement(); @@ -50,8 +56,9 @@ function UserDevicesView() { return peers.map((peer) => ({ ...peer, user: users.find((u) => u.id === peer.user_id), + force_approved: peer.id ? isBypassed(peer.id) : false, })); - }, [peers, users]); + }, [peers, users, isBypassed]); return ( <> diff --git a/src/app/(dashboard)/reverse-proxy/services/page.tsx b/src/app/(dashboard)/reverse-proxy/services/page.tsx index 9da2c08..691e95b 100644 --- a/src/app/(dashboard)/reverse-proxy/services/page.tsx +++ b/src/app/(dashboard)/reverse-proxy/services/page.tsx @@ -1,13 +1,11 @@ "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"; import { RestrictedAccess } from "@components/ui/RestrictedAccess"; import { usePortalElement } from "@hooks/usePortalElement"; -import { isNetBirdHosted } from "@utils/netbird"; import { ExternalLinkIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import React, { lazy, Suspense } from "react"; @@ -16,6 +14,8 @@ 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 { isNetBirdCloud } from "@utils/netbird"; const ReverseProxyTable = lazy( () => import("@/modules/reverse-proxy/table/ReverseProxyTable"), @@ -53,7 +53,7 @@ export default function ReverseProxyServicesPage() { - {isNetBirdHosted() ? ( +{isNetBirdCloud() ? ( {t("betaNoticeCloud")} diff --git a/src/app/(remote-access)/layout.tsx b/src/app/(remote-access)/layout.tsx index c0798f3..cd98859 100644 --- a/src/app/(remote-access)/layout.tsx +++ b/src/app/(remote-access)/layout.tsx @@ -1,9 +1,12 @@ "use client"; +import MSPProvider from "@/cloud/msp/contexts/MSPProvider"; import UsersProvider from "@/contexts/UsersProvider"; export default function Layout({ children }: { children: React.ReactNode }) { return ( + {children} + ); } diff --git a/src/app/(remote-access)/peer/rdp/page.tsx b/src/app/(remote-access)/peer/rdp/page.tsx index 810ea03..f7bc709 100644 --- a/src/app/(remote-access)/peer/rdp/page.tsx +++ b/src/app/(remote-access)/peer/rdp/page.tsx @@ -84,9 +84,7 @@ 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( diff --git a/src/app/install/page.tsx b/src/app/install/page.tsx index 5321012..0fa143e 100644 --- a/src/app/install/page.tsx +++ b/src/app/install/page.tsx @@ -1,19 +1,53 @@ "use client"; import { Modal } from "@components/modal/Modal"; +import { AnnouncementBanner } from "@components/ui/AnnouncementBanner"; +import { useIsMd } from "@utils/responsive"; import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import AnnouncementProvider, { + useAnnouncement, +} from "@/contexts/AnnouncementProvider"; import SetupModal from "@/modules/setup-netbird-modal/SetupModal"; -export default function UnauthenticatedInstallModal() { +function InstallContent() { const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const { bannerHeight } = useAnnouncement(); + const isMd = useIsMd(); useEffect(() => { setOpen(true); + setMounted(true); }, []); return ( - null} open={open}> - - + <> + {mounted && + createPortal( +
+ +
, + document.body, + )} + null} open={open}> + + + + ); +} + +export default function UnauthenticatedInstallModal() { + return ( + + + ); } diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index a77a5e5..7c46c72 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -11,15 +11,18 @@ type Props = { }; export default function NotFound() { const [mounted, setMounted] = useState(false); - const [tempQueryParams, setTempQueryParams] = useLocalStorage( - "netbird-query-params", - "", - ); + const [tempQueryParams, setTempQueryParams] = useLocalStorage<{ + path: string; + params: string; + } | null>("netbird-query-params", null); const [queryParams, setQueryParams] = useState(""); useEffect(() => { - setQueryParams(tempQueryParams); - setTempQueryParams(""); + const currentPath = window.location.pathname || "/"; + if (tempQueryParams?.path === currentPath && tempQueryParams?.params) { + setQueryParams(tempQueryParams.params); + } + setTempQueryParams(null); setMounted(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/app/page.tsx b/src/app/page.tsx index f603b3e..77eaafd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,15 +12,18 @@ type Props = { export default function Home() { const [mounted, setMounted] = useState(false); - const [tempQueryParams, setTempQueryParams] = useLocalStorage( - "netbird-query-params", - "", - ); + const [tempQueryParams, setTempQueryParams] = useLocalStorage<{ + path: string; + params: string; + } | null>("netbird-query-params", null); const [queryParams, setQueryParams] = useState(""); useEffect(() => { - setQueryParams(tempQueryParams); - setTempQueryParams(""); + const currentPath = window.location.pathname || "/"; + if (tempQueryParams?.path === currentPath && tempQueryParams?.params) { + setQueryParams(tempQueryParams.params); + } + setTempQueryParams(null); setMounted(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/assets/avatars/jack.jpeg b/src/assets/avatars/jack.jpeg new file mode 100644 index 0000000..dce3787 Binary files /dev/null and b/src/assets/avatars/jack.jpeg differ diff --git a/src/assets/icons/CircleIcon.tsx b/src/assets/icons/CircleIcon.tsx index f4210c5..72ae5bd 100644 --- a/src/assets/icons/CircleIcon.tsx +++ b/src/assets/icons/CircleIcon.tsx @@ -16,8 +16,7 @@ export default function CircleIcon({ return ( ) { + return ( + + + + ); +} diff --git a/src/assets/integrations/aws-marketplace.svg b/src/assets/integrations/aws-marketplace.svg new file mode 100644 index 0000000..3200538 --- /dev/null +++ b/src/assets/integrations/aws-marketplace.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/integrations/crowdstrike.png b/src/assets/integrations/crowdstrike.png new file mode 100644 index 0000000..476fc27 Binary files /dev/null and b/src/assets/integrations/crowdstrike.png differ diff --git a/src/assets/integrations/firehose.png b/src/assets/integrations/firehose.png new file mode 100644 index 0000000..be44755 Binary files /dev/null and b/src/assets/integrations/firehose.png differ diff --git a/src/assets/integrations/fleetdm.png b/src/assets/integrations/fleetdm.png new file mode 100644 index 0000000..0e6cabe Binary files /dev/null and b/src/assets/integrations/fleetdm.png differ diff --git a/src/assets/integrations/generic-http.png b/src/assets/integrations/generic-http.png new file mode 100644 index 0000000..dff00f8 Binary files /dev/null and b/src/assets/integrations/generic-http.png differ diff --git a/src/assets/integrations/generic-scim.png b/src/assets/integrations/generic-scim.png new file mode 100644 index 0000000..e547a33 Binary files /dev/null and b/src/assets/integrations/generic-scim.png differ diff --git a/src/assets/integrations/huntress.png b/src/assets/integrations/huntress.png new file mode 100644 index 0000000..af2424e Binary files /dev/null and b/src/assets/integrations/huntress.png differ diff --git a/src/assets/integrations/intune.png b/src/assets/integrations/intune.png new file mode 100644 index 0000000..c348ce6 Binary files /dev/null and b/src/assets/integrations/intune.png differ diff --git a/src/assets/integrations/jumpcloud.png b/src/assets/integrations/jumpcloud.png new file mode 100644 index 0000000..4841f80 Binary files /dev/null and b/src/assets/integrations/jumpcloud.png differ diff --git a/src/assets/integrations/keycloak.png b/src/assets/integrations/keycloak.png new file mode 100644 index 0000000..6882952 Binary files /dev/null and b/src/assets/integrations/keycloak.png differ diff --git a/src/assets/integrations/s3.svg b/src/assets/integrations/s3.svg new file mode 100644 index 0000000..cd203ea --- /dev/null +++ b/src/assets/integrations/s3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/integrations/sentinelone.png b/src/assets/integrations/sentinelone.png new file mode 100644 index 0000000..80264db Binary files /dev/null and b/src/assets/integrations/sentinelone.png differ diff --git a/src/assets/integrations/slack.png b/src/assets/integrations/slack.png new file mode 100644 index 0000000..ae6f025 Binary files /dev/null and b/src/assets/integrations/slack.png differ diff --git a/src/auth/SecureProvider.tsx b/src/auth/SecureProvider.tsx index 1e91443..68b2c24 100644 --- a/src/auth/SecureProvider.tsx +++ b/src/auth/SecureProvider.tsx @@ -4,7 +4,9 @@ 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"]; + +type StoredQueryParams = { path: string; params: string }; + const VALID_PARAMS = [ "tab", "search", @@ -21,6 +23,15 @@ const VALID_PARAMS = [ "port", ]; +function filterAllowedParams(raw: string): string { + const input = new URLSearchParams(raw); + const output = new URLSearchParams(); + for (const key of VALID_PARAMS) { + for (const v of input.getAll(key)) output.append(key, v); + } + return output.toString(); +} + type Props = { children: React.ReactNode; }; @@ -29,15 +40,32 @@ export const SecureProvider = ({ children }: Props) => { const currentPath = usePathname(); useEffect(() => { - if (isAuthenticated && !PRESERVE_QUERY_PARAMS_PATHS.includes(currentPath)) { - localStorage.removeItem(QUERY_PARAMS_KEY); - } else if (!isAuthenticated) { + if (isAuthenticated) { + try { + const stored = localStorage.getItem(QUERY_PARAMS_KEY); + if (stored && !window.location.search) { + const data: StoredQueryParams = JSON.parse(stored); + if (data?.path === currentPath && data?.params) { + localStorage.removeItem(QUERY_PARAMS_KEY); + window.history.replaceState( + null, + "", + `${currentPath}?${data.params}`, + ); + } + } + } catch (e) {} + } else { 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)); + const filtered = filterAllowedParams(params); + if (filtered) { + const data: StoredQueryParams = { + path: currentPath, + params: filtered, + }; + localStorage.setItem(QUERY_PARAMS_KEY, JSON.stringify(data)); } } } catch (e) {} diff --git a/src/cloud/analytics/Hubspot.tsx b/src/cloud/analytics/Hubspot.tsx new file mode 100644 index 0000000..7b97bdc --- /dev/null +++ b/src/cloud/analytics/Hubspot.tsx @@ -0,0 +1,208 @@ +import { useOidcUser } from "@axa-fr/react-oidc"; +import { useLocalStorage } from "@hooks/useLocalStorage"; +import loadConfig from "@utils/config"; +import dayjs from "dayjs"; +import Cookies from "js-cookie"; +import { useSearchParams } from "next/navigation"; +import * as React from "react"; +import { useEffect, useMemo, useState } from "react"; +import { HubspotFormField, useAnalytics } from "@/contexts/AnalyticsProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { useAccount } from "@/modules/account/useAccount"; + +const config = loadConfig(); +const formsApiUrl = `https://api.hsforms.com/submissions/v3/integration/submit/${config.hubspotPortalId}`; // Hubspot v2 Forms API + +const detectDeviceType = () => { + if (typeof navigator === "undefined") { + return "unknown"; + } + + const ua = navigator.userAgent.toLowerCase(); + const isTablet = + /tablet|ipad|playbook|silk/.test(ua) || + (/android/.test(ua) && !/mobile/.test(ua)); + if (isTablet) { + return "tablet"; + } + + const isMobile = + /mobi|iphone|ipod|blackberry|phone/.test(ua) || + (/android/.test(ua) && /mobile/.test(ua)); + if (isMobile) { + return "mobile"; + } + + return "desktop"; +}; + +export const Hubspot = () => { + const { loggedInUser, isOwner } = useLoggedInUser(); + const { oidcUser } = useOidcUser(); + const account = useAccount(); + const params = useSearchParams(); + const [mounted, setMounted] = useState(false); + const [deviceType, setDeviceType] = useState("unknown"); + const utmSource = params?.get("utm_source") ?? ""; + const utmMedium = params?.get("utm_medium") ?? ""; + const utmContent = params?.get("utm_content") ?? ""; + const utmCampaign = params?.get("utm_campaign") ?? ""; + const hsId = params?.get("hs_id") ?? ""; + const gaId = params?.get("ga_id") ?? ""; + + // Submit form only when localStorage key is false + const [submittedSignUpForm, setSubmittedSignUpForm] = useLocalStorage( + "netbird-signup-form", + false, + ); + + useEffect(() => { + setMounted(true); + setDeviceType(detectDeviceType()); + }, []); + + const isNewAccount = useMemo(() => { + try { + return ( + account?.created_at && + dayjs(account?.created_at).isAfter(dayjs().subtract(10, "minute")) + ); + } catch (e) { + return false; + } + }, [account]); + + return ( + account && + loggedInUser && + isOwner && + !submittedSignUpForm && + isNewAccount && + mounted && + config.hubspotSignupFormId && ( + setSubmittedSignUpForm(true)} + id={config.hubspotSignupFormId} + hubspotQueryId={hsId} + gaId={gaId} + fields={[ + { + name: "email", + value: oidcUser?.email || loggedInUser?.email || "", + }, + { + name: "firstname", + value: + oidcUser?.given_name || + oidcUser?.name || + loggedInUser?.name || + "", + }, + { + name: "lastname", + value: oidcUser?.family_name || "", + }, + { + name: "utm_source", + value: utmSource, + }, + { + name: "utm_medium", + value: utmMedium, + }, + { + name: "utm_content", + value: utmContent, + }, + { + name: "utm_campaign", + value: utmCampaign, + }, + { + name: "account_id", + value: account?.id, + }, + { + name: "is_owner", + value: "true", + }, + { + name: "device_type", + value: deviceType, + }, + ]} + /> + ) + ); +}; + +type FormProps = { + id: string; + fields: HubspotFormField[]; + onSuccess?: () => void; + hubspotQueryId?: string; + gaId?: string; +}; + +export const HubspotForm = ({ + id, + fields, + onSuccess, + hubspotQueryId, + gaId, +}: FormProps) => { + const { trackGTMCustomEvent, trackEvent } = useAnalytics(); + + useEffect(() => { + const submit = async () => { + try { + trackGTMCustomEvent("Lead"); + trackGTMCustomEvent("New Sign-up"); + trackEvent("New Sign-up", "New Sign-up", "New Sign-up"); + return await submitHubspotForm({ id, fields, hubspotQueryId, gaId }); + } catch (error) {} + }; + + // Wait before submitting the form (getting hubspot id from cookie takes some time while hubspot is initializing) + setTimeout(() => submit().then(() => onSuccess?.()), 3500); + }, []); + + return null; +}; + +export const submitHubspotForm = async ({ + id, + fields, + hubspotQueryId, + gaId, +}: FormProps) => { + try { + if (!config.hubspotPortalId || !id) return; + + // Do not submit forms for excluded accounts, e.g., synthetic test users + const email = fields?.find((field) => field?.name === "email")?.value; + if (email && config.analyticsExcludedEmails.includes(email)) { + return; + } + + return fetch(formsApiUrl + `/${id}`, { + method: "POST", + body: JSON.stringify({ + submittedAt: dayjs().valueOf(), + fields: [ + ...fields, + { name: "gaid", value: gaId || Cookies.get("_ga") || "" }, + ], + context: { + hutk: Cookies.get("hubspotutk") || hubspotQueryId || undefined, + pageName: document?.title || "", + pageUri: window?.location?.href, + }, + }), + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + } catch (error) {} +}; diff --git a/src/cloud/aws/AWSChoosePlan.tsx b/src/cloud/aws/AWSChoosePlan.tsx new file mode 100644 index 0000000..dbc2554 --- /dev/null +++ b/src/cloud/aws/AWSChoosePlan.tsx @@ -0,0 +1,232 @@ +import Button from "@components/Button"; +import Card from "@components/Card"; +import { Modal, ModalPortal } from "@components/modal/Modal"; +import { NetBirdLogo } from "@components/NetBirdLogo"; +import Paragraph from "@components/Paragraph"; +import { DialogContent } from "@radix-ui/react-dialog"; +import { cn } from "@utils/helpers"; +import { LogOutIcon, MailIcon, PlusIcon } from "lucide-react"; +import Image from "next/image"; +import * as React from "react"; +import { useEffect, useState } from "react"; +import Skeleton from "react-loading-skeleton"; +import AWSMarketplaceLogo from "@/assets/integrations/aws-marketplace.svg"; +import { useBilling } from "@/contexts/BillingProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { Plan } from "@/interfaces/Plan"; +import { PlanTier } from "@/interfaces/Subscription"; +import { PlanCard, PlanLoadingSkeleton } from "@/modules/billing/PlanCard"; + +type Props = { + onSuccess?: () => void; +}; + +export const AWSChoosePlan = ({ onSuccess }: Props) => { + const [open, setOpen] = useState(true); + const { + plans, + isLoading, + subscription, + currency, + changeSubscription, + isTrial, + subscribe, + } = useBilling(); + const { logout } = useLoggedInUser(); + + const teamAndBusinessPlans = plans?.filter( + (plan) => + plan.name.toLowerCase().includes("team") || + plan.name.toLowerCase().includes("business"), + ); + + const [isSubscribing, setIsSubscribing] = useState({ + team: false, + business: false, + }); + + const hasActiveStripePlan = + subscription?.active === true && subscription?.provider !== "aws"; + + const hasPlan = + subscription?.plan_tier !== PlanTier.FREE && + subscription?.plan_tier !== PlanTier.TRIAL; + + const hasActiveAWSPlan = hasPlan && subscription?.provider === "aws"; + + // If there is an active AWS plan, skip the plan selection + useEffect(() => { + if (hasActiveAWSPlan) { + onSuccess?.(); + setOpen(false); + } + }, [hasActiveAWSPlan]); + + const subscribeToPlan = async (plan: Plan) => { + let name = plan?.name?.toLowerCase() || ""; + setIsSubscribing({ + team: name === PlanTier.TEAM, + business: name === PlanTier.BUSINESS, + }); + if (hasPlan && !isTrial) { + changeSubscription(plan, true).then(() => { + onSuccess?.(); + setOpen(false); + }); + } else { + subscribe(plan, true).then(() => { + onSuccess?.(); + setOpen(false); + }); + } + setIsSubscribing({ + team: false, + business: false, + }); + }; + + return ( + + + +
+
+
+ {"AWS + + + + +
+ + {isLoading ? ( + + ) : ( + +

+ Thanks for registering via{" "} +
+ AWS Marketplace! +

+ + {hasActiveStripePlan ? ( + <> + It seems you already have an active subscription with + us. In order to use AWS as a billing provider, please + contact our support team. + + ) : ( + <> + With our flexible pricing, you are only billed for + active users and active peers through your AWS account. + Please choose the plan that fits your needs.
+ You can always change your plan later. + + )} +
+ {!hasActiveStripePlan ? ( +
+ {!plans && ( + <> + + + + )} + {!isLoading && + teamAndBusinessPlans?.map((plan) => { + return ( + subscribeToPlan(plan)} + key={plan.name} + /> + ); + })} +
+ ) : ( +
+ + + + +
+ )} +
+ )} + + {!hasActiveStripePlan && ( + + )} +
+
+
+
+
+ ); +}; diff --git a/src/cloud/aws/useAWSMarketplace.ts b/src/cloud/aws/useAWSMarketplace.ts new file mode 100644 index 0000000..8ac55f3 --- /dev/null +++ b/src/cloud/aws/useAWSMarketplace.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react"; + +export const AWS_MARKETPLACE_LOCAL_STORAGE_KEY = "netbird-aws-marketplace"; + +/** + * Store aws_user_id query into localStorage + */ +export function useAWSMarketplace() { + useEffect(() => { + if (typeof window === "undefined") return; + + const params = new URLSearchParams(window.location.search); + const awsUserId = params.get("aws_user_id"); + + if (awsUserId) { + try { + localStorage.setItem(AWS_MARKETPLACE_LOCAL_STORAGE_KEY, awsUserId); + } catch (e) {} + } + }, []); +} diff --git a/src/cloud/cloud-hooks/useAuthService.ts b/src/cloud/cloud-hooks/useAuthService.ts new file mode 100644 index 0000000..3661e82 --- /dev/null +++ b/src/cloud/cloud-hooks/useAuthService.ts @@ -0,0 +1,22 @@ +import { useApiCall } from "@utils/api"; +import loadConfig from "@utils/config"; +import { EnterpriseConnection } from "@/interfaces/IdentityProvider"; + +const config = loadConfig(); +export const useAuthService = () => { + const accountRequest = useApiCall( + "/service/account", + true, + { + origin: config.authServiceUrl, + }, + ); + + const deleteAccount = async () => { + return accountRequest.del({}); + }; + + return { + deleteAccount, + } as const; +}; diff --git a/src/cloud/cloud-hooks/useCookies.ts b/src/cloud/cloud-hooks/useCookies.ts new file mode 100644 index 0000000..db3161a --- /dev/null +++ b/src/cloud/cloud-hooks/useCookies.ts @@ -0,0 +1,121 @@ +//@ts-nocheck +"use client"; +import Cookies from "js-cookie"; +import { useCallback, useEffect, useState } from "react"; + +type UseCookieReturn = [ + T | null, + (newValue: T, options?: unknown) => void, + () => void, +]; + +async function getCookie(cookieName: string): Promise { + if ("cookieStore" in window) { + return (await cookieStore.get(cookieName))?.value ?? null; + } + + return Cookies.get(cookieName) ?? null; +} + +async function setCookie( + cookieName: string, + value: string, + options?: unknown, +): Promise { + if ("cookieStore" in window) { + return cookieStore.set(cookieName, value, options); + } + + Cookies.set(cookieName, value, options as CookieAttributes); +} + +async function deleteCookie(cookie: string): Promise { + if ("cookieStore" in window) { + return cookieStore.delete(cookie); + } + + Cookies.remove(cookie); +} + +export function listenForCookieChange( + cookieName: string, + onChange: (newValue: string | null) => void, +): () => void { + if ("cookieStore" in window) { + const changeListener = (event) => { + const foundCookie = event.changed.find( + (cookie) => cookie.name === cookieName, + ); + if (foundCookie) { + onChange(foundCookie.value); + return; + } + + const deletedCookie = event.deleted.find( + (cookie) => cookie.name === cookieName, + ); + if (deletedCookie) { + onChange(null); + } + }; + + cookieStore.addEventListener("change", changeListener); + + return () => { + cookieStore.removeEventListener("change", changeListener); + }; + } + + const interval = setInterval(() => { + const cookie = Cookies.get(cookieName); + if (cookie) { + onChange(cookie); + } else { + onChange(null); + } + }, 1000); + + return () => { + clearInterval(interval); + }; +} + +export default function useCookieWithListener( + name: string, + defaultValue: T, +): UseCookieReturn { + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + getCookie(name).then((cookie) => { + if (cookie) { + try { + setValue(JSON.parse(cookie)); + } catch (err) { + setValue(cookie as T); + } + } + }); + + return listenForCookieChange(name, (newValue) => { + try { + setValue(newValue ? JSON.parse(newValue) : null); + } catch (err) { + setValue(newValue as T); + } + }); + }, [name]); + + const updateCookie = useCallback( + (newValue: T, options?: unknown) => { + setCookie(name, JSON.stringify(newValue), options); + }, + [name], + ); + + const _deleteCookie = useCallback(() => { + deleteCookie(name); + }, [name]); + + return [value, updateCookie, _deleteCookie]; +} diff --git a/src/cloud/cloud-hooks/useDomainCategory.tsx b/src/cloud/cloud-hooks/useDomainCategory.tsx new file mode 100644 index 0000000..a1d5704 --- /dev/null +++ b/src/cloud/cloud-hooks/useDomainCategory.tsx @@ -0,0 +1,35 @@ +import { useOidcAccessToken, useOidcIdToken } from "@axa-fr/react-oidc"; +import loadConfig from "@utils/config"; +import { useMemo } from "react"; +import { useJwt } from "react-jwt"; + +const config = loadConfig(); +export const useDomainCategory = () => { + const tokenSource = config?.tokenSource || "accessToken"; + const { idToken } = useOidcIdToken(); + const { accessToken } = useOidcAccessToken(); + const token = tokenSource.toLowerCase() == "idtoken" ? idToken : accessToken; + const { decodedToken } = useJwt>(token); + + const domainCategory = useMemo(() => { + try { + const key = decodedToken + ? Object.keys(decodedToken) + .filter((key) => key.includes("wt_account_domain_category")) + .pop() + : undefined; + return key && decodedToken ? (decodedToken[key] as string) : undefined; + } catch (e) { + return undefined; + } + }, [decodedToken]); + + const isPrivate = useMemo(() => { + return domainCategory === "private"; + }, [domainCategory]); + + return { + domainCategory, + isPrivate, + }; +}; diff --git a/src/cloud/cloud-hooks/useExperiment.ts b/src/cloud/cloud-hooks/useExperiment.ts new file mode 100644 index 0000000..b282326 --- /dev/null +++ b/src/cloud/cloud-hooks/useExperiment.ts @@ -0,0 +1,74 @@ +"use client"; +import { useCallback, useEffect, useState } from "react"; + +const STORAGE_KEY = "netbird-experiments"; + +interface ExperimentStorage { + [experimentId: string]: string; +} + +function getStoredExperiments(): ExperimentStorage { + if (typeof window === "undefined") return {}; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } +} + +function storeExperiment(experimentId: string, variant: string): void { + if (typeof window === "undefined") return; + + try { + const experiments = getStoredExperiments(); + experiments[experimentId] = variant; + localStorage.setItem(STORAGE_KEY, JSON.stringify(experiments)); + } catch { + // Silently fail if localStorage is not available + } +} + +function selectRandomVariant(variants: string[]): string { + return variants[Math.floor(Math.random() * variants.length)]; +} + +export function useExperiment>( + experimentId: string, + variants: T, +): [T[keyof T], string] { + const [selectedVariant, setSelectedVariant] = useState(null); + const [selectedKey, setSelectedKey] = useState(""); + + const variantKeys = Object.keys(variants); + const variantKeysString = variantKeys.join(","); + + useEffect(() => { + if (!experimentId || variantKeys.length === 0) { + setSelectedVariant(null); + setSelectedKey(""); + return; + } + + const storedExperiments = getStoredExperiments(); + const existingVariant = storedExperiments[experimentId]; + + if (existingVariant && existingVariant in variants) { + setSelectedVariant(variants[existingVariant]); + setSelectedKey(existingVariant); + return; + } + + const randomKey = selectRandomVariant(variantKeys); + storeExperiment(experimentId, randomKey); + setSelectedVariant(variants[randomKey]); + setSelectedKey(randomKey); + }, [experimentId, variantKeysString]); + + const defaultKey = variantKeys[0] || ""; + return [ + selectedVariant || (defaultKey ? variants[defaultKey] : null), + selectedKey || defaultKey, + ]; +} diff --git a/src/cloud/cloud-hooks/useIsFeatureLocked.tsx b/src/cloud/cloud-hooks/useIsFeatureLocked.tsx new file mode 100644 index 0000000..400dece --- /dev/null +++ b/src/cloud/cloud-hooks/useIsFeatureLocked.tsx @@ -0,0 +1,75 @@ +import { isNetBirdCloud, testEditionOverride } from "@utils/netbird"; +import { useTrial } from "@/cloud/cloud-hooks/useTrial"; +import { useBilling } from "@/contexts/BillingProvider"; +import { useIsLicensed } from "@/hooks/useIsLicensed"; +import { PlanTier } from "@/interfaces/Subscription"; + +export enum PlanFeatures { + IDP_SYNC = "IDP_SYNC", + DEVICE_APPROVALS = "DEVICE_APPROVALS", + EDR = "EDR", + POSTURE_CHECKS = "POSTURE_CHECKS", + EVENT_STREAMING = "EVENT_STREAMING", + MSP = "MSP", + TRAFFIC_EVENTS = "TRAFFIC_EVENTS", +} + +/** + * Used to show "Available on Business" etc. labels when a specific feature is locked + */ + +export const PlanFeatureAvailability = { + [PlanFeatures.IDP_SYNC]: PlanTier.TEAM, + [PlanFeatures.MSP]: PlanTier.TEAM, + [PlanFeatures.EDR]: PlanTier.BUSINESS, + [PlanFeatures.DEVICE_APPROVALS]: PlanTier.BUSINESS, + [PlanFeatures.POSTURE_CHECKS]: PlanTier.BUSINESS, + [PlanFeatures.EVENT_STREAMING]: PlanTier.BUSINESS, + [PlanFeatures.TRAFFIC_EVENTS]: PlanTier.BUSINESS, +}; + +/** + * Features served by the open-source management server. They stay unlocked on + * every self-hosted deployment regardless of license. + */ +const OPEN_SOURCE_FEATURES: Array = [ + "POSTURE_CHECKS", + "DEVICE_APPROVALS", +]; + +/** + * Hook to check if a feature is locked based on the current plan. + * On NetBird Cloud the lock follows the subscription plan, for trial users it + * always returns false. On self-hosted deployments features included in the + * open-source management server are always unlocked, the rest follow the + * license (NETBIRD_LICENSED or the licensed management server probe). + */ + +export const useIsFeatureLocked = (feature: keyof typeof PlanFeatures) => { + const { subscription, isLoading } = useBilling(); + const { isTrial, currentPlan } = useTrial(); + const { isLicensed } = useIsLicensed(); + + if (process.env.APP_ENV === "test" && !testEditionOverride()) return false; + + if (!isNetBirdCloud()) { + if (OPEN_SOURCE_FEATURES.includes(feature)) return false; + return !isLicensed; + } + if (isTrial) return false; + + // Lock all features for free users + if (!currentPlan) return true; + let planName = currentPlan.name.toLowerCase(); + if (planName.includes("free")) return true; + + // Lock while billing is loading + if (isLoading) return true; + + // Lock features based on what is available in subscription.features + if (subscription?.features && subscription?.features?.length > 0) { + if (subscription.features.includes(PlanFeatures[feature])) return false; + } + + return true; +}; diff --git a/src/cloud/cloud-hooks/useTrial.tsx b/src/cloud/cloud-hooks/useTrial.tsx new file mode 100644 index 0000000..978f0ab --- /dev/null +++ b/src/cloud/cloud-hooks/useTrial.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { BillingContext, useBilling } from "@/contexts/BillingProvider"; +import { Plan } from "@/interfaces/Plan"; + +/** + * Hook to get the trial status and plan information + * For self-hosted cloud, this hook will always return the default state + */ + +const defaultState = { + isTrial: false, + isTrialAvailable: true, + trialDaysRemaining: undefined, + currentPlan: { + name: "free", + }, + startTrial: async (plan: Plan) => false, + plans: undefined, + canUpgrade: false, +}; + +export const useTrial = () => { + const billingContext = React.useContext(BillingContext); + + if (typeof useBilling === "undefined") return defaultState; + if (!useBilling) return defaultState; + if (!billingContext) return defaultState; + + return { + isTrial: billingContext.isTrial, + isTrialAvailable: billingContext.isTrialAvailable, + trialDaysRemaining: billingContext.trialDaysRemaining, + currentPlan: billingContext.currentPlan, + startTrial: billingContext.startTrial, + canUpgrade: billingContext.canUpgrade, + plans: billingContext.plans, + }; +}; diff --git a/src/cloud/contexts/NetBirdCloudProvider.tsx b/src/cloud/contexts/NetBirdCloudProvider.tsx new file mode 100644 index 0000000..bd93687 --- /dev/null +++ b/src/cloud/contexts/NetBirdCloudProvider.tsx @@ -0,0 +1,89 @@ +import { useApiCall } from "@utils/api"; +import loadConfig from "@utils/config"; +import { isNetBirdCloud } from "@utils/netbird"; +import * as React from "react"; +import { useEffect, useState } from "react"; +import { useSWRConfig } from "swr"; +import { Hubspot, submitHubspotForm } from "@/cloud/analytics/Hubspot"; +import { AWSChoosePlan } from "@/cloud/aws/AWSChoosePlan"; +import { AWS_MARKETPLACE_LOCAL_STORAGE_KEY } from "@/cloud/aws/useAWSMarketplace"; +import { useDomainCategory } from "@/cloud/cloud-hooks/useDomainCategory"; +import HowDidYouHearAboutUs from "@/cloud/survey/HowDidYouHearAboutUs"; +import { useAnalytics } from "@/contexts/AnalyticsProvider"; +import { useBilling } from "@/contexts/BillingProvider"; +import type { Group } from "@/interfaces/Group"; +import { PlanTier } from "@/interfaces/Subscription"; +import { OnboardingProvider } from "@/modules/onboarding/OnboardingProvider"; + +export const NetBirdCloudProvider = () => { + const { mutate } = useSWRConfig(); + const { subscription } = useBilling(); + const [awsUserId, setAwsUserId] = useState(); + const { trackEvent, trackEventV2 } = useAnalytics(); + const { domainCategory } = useDomainCategory(); + const awsRequest = useApiCall( + "/integrations/billing/aws/marketplace/enrich", + true, + ).post; + + useEffect(() => { + try { + const id = localStorage.getItem(AWS_MARKETPLACE_LOCAL_STORAGE_KEY); + if (id) { + awsRequest({ + aws_user_id: id, + }).then(() => { + mutate("/integrations/billing/subscription"); + setAwsUserId(id); + localStorage.removeItem("netbird-aws-marketplace"); + }); + } + } catch (e) {} + }, []); + + const hasFreeOrTrialAWSPlan = + subscription?.plan_tier === PlanTier.FREE || + subscription?.plan_tier === PlanTier.TRIAL; + + const showAWSPlanSelection = + (hasFreeOrTrialAWSPlan && subscription?.provider === "aws") || awsUserId; + + return ( + <> + {/* Force user to select an initial plan when coming from AWS Marketplace */} + {showAWSPlanSelection && ( + { + setAwsUserId(undefined); + }} + /> + )} + + {/* Hide onboarding while users selects a plan */} + {!showAWSPlanSelection && isNetBirdCloud() && ( + { + const { fields, hsId, gaId, accountId, userId } = data; + try { + await submitHubspotForm({ + id: loadConfig().hubspotOnboardingFormId ?? "", + fields, + hubspotQueryId: hsId, + gaId, + }); + trackEvent("Onboarding", "onboarding_submit", "Form Submit"); + trackEventV2("Onboarding", "Submitted Form", accountId, userId); + } catch (error) {} + }} + domainCategory={domainCategory} + /> + )} + + {/* Show survey */} + + + {/* Hubspot tracking for new accounts */} + + + ); +}; diff --git a/src/cloud/distributor/DistributorAccountExistsModal.tsx b/src/cloud/distributor/DistributorAccountExistsModal.tsx new file mode 100644 index 0000000..a70fa87 --- /dev/null +++ b/src/cloud/distributor/DistributorAccountExistsModal.tsx @@ -0,0 +1,81 @@ +import Button from "@components/Button"; +import { Callout } from "@components/Callout"; +import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import { GlobeIcon } from "lucide-react"; +import * as React from "react"; +import { DistributorCustomer } from "@/cloud/distributor/interfaces/Distributor"; + +type Props = { + open: boolean; + setOpen: React.Dispatch>; + customer: DistributorCustomer; + onAccept: (c: DistributorCustomer) => void; + onCancel: (c: DistributorCustomer) => void; +}; + +export const DistributorAccountExistsModal = ({ + open, + setOpen, + customer, + onAccept, + onCancel, +}: Props) => { + return ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + > + +
+
+ + {customer?.domain} +
+
+ This NetBird account already
+ exists in our system +
+
+ To manage the account{" "} + {customer?.domain}, you must + first request access from the account owner. +
+
+ + The account owner must log in to the dashboard to accept or decline + your request. Please inform them after you have requested access. + +
+ + + + + +
+
+ ); +}; diff --git a/src/cloud/distributor/DistributorCustomerModal.tsx b/src/cloud/distributor/DistributorCustomerModal.tsx new file mode 100644 index 0000000..4922479 --- /dev/null +++ b/src/cloud/distributor/DistributorCustomerModal.tsx @@ -0,0 +1,377 @@ +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import InlineLink from "@components/InlineLink"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import { notify } from "@components/Notification"; +import Paragraph from "@components/Paragraph"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; +import Separator from "@components/Separator"; +import { useApiCall } from "@utils/api"; +import { cn, validator } from "@utils/helpers"; +import { + CreditCardIcon, + ExternalLinkIcon, + GlobeIcon, + PlusCircle, + Text, + UserIcon, +} from "lucide-react"; +import * as React from "react"; +import { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import { DistributorDocsLink } from "@/cloud/distributor/DistributorDocsLink"; +import { useCustomerPlan } from "@/cloud/distributor/hooks/useCustomerPlan"; +import { + DistributorCustomer, + DistributorCustomerStatus, +} from "@/cloud/distributor/interfaces/Distributor"; +import { PlanCard, PlanLoadingSkeleton } from "@/modules/billing/PlanCard"; + +type Props = { + open: boolean; + setOpen: (open: boolean) => void; + customer?: DistributorCustomer; + initialTab?: string; + onCreated?: (customer: DistributorCustomer) => void; +}; + +export const DistributorCustomerModal = ({ + open, + setOpen, + customer, + initialTab, + onCreated, +}: Props) => { + return ( + + + + ); +}; + +const ModalWidth = { + general: "max-w-xl", + plan: "max-w-3xl", +} as Record; + +const CustomerModalContent = ({ + setOpen, + customer, + initialTab, + onCreated, +}: Omit) => { + const { mutate } = useSWRConfig(); + const customerRequest = useApiCall( + "/integrations/msp/reseller/msps", + true, + ); + + const [tab, setTab] = useState(initialTab || "general"); + const [name, setName] = useState(customer?.name || ""); + const [resellerCustomerId, setResellerCustomerId] = useState( + customer?.reseller_customer_id || "", + ); + const [domain, setDomain] = useState(customer?.domain || ""); + + const domainInputError = useMemo(() => { + if (domain === "") return ""; + if (!validator.isValidDomain(domain)) { + return "Please enter a valid domain, e.g. netbird.io"; + } + return ""; + }, [domain]); + + const createCustomer = async () => { + const body: Record = { + name, + domain, + }; + if (resellerCustomerId) body.reseller_customer_id = resellerCustomerId; + const promise = customerRequest.post(body).then((res) => { + const c = res as DistributorCustomer; + mutate("/integrations/msp/reseller/msps"); + setOpen(false); + onCreated?.(c); + }); + + notify({ + title: `Add ${domain} customer`, + description: "The customer account has been created successfully.", + preventSuccessToast: true, + loadingMessage: "Creating customer account...", + promise, + }); + }; + + const saveCustomer = async () => { + if (!customer) return; + const body: Record = { name }; + body.reseller_customer_id = resellerCustomerId; + const promise = customerRequest.put(body, `/${customer.id}`).then(() => { + mutate("/integrations/msp/reseller/msps"); + setOpen(false); + }); + + notify({ + title: `Update ${customer.name}`, + description: "The customer has been updated successfully.", + loadingMessage: "Updating customer...", + promise, + }); + }; + + const canCreate = + domainInputError === "" && + name !== "" && + domain !== ""; + + const hasChanges = + name !== (customer?.name || "") || + resellerCustomerId !== (customer?.reseller_customer_id || ""); + + const isActive = customer?.status === DistributorCustomerStatus.Active; + + return ( + + } + title={customer ? "Edit Customer" : "Add Customer"} + description={ + customer + ? `${customer.name} (${customer.domain})` + : "Add a new customer account to your distributor organization." + } + color={"netbird"} + /> + {customer ? ( + + + + + General + + {isActive && ( + + + Plan + + )} + + +
+
+ + + Enter the name of your customers company. + + setName(e.target.value)} + placeholder={"Acme Inc."} + className={"min-w-[270px]"} + /> +
+
+ + + The domain associated with this customer account. + + } + tabIndex={0} + value={domain} + error={domainInputError} + disabled={true} + placeholder={"acme-inc.com"} + className={"w-full"} + /> +
+ +
+ + + An optional identifier to easier map customers to your{" "} + internal systems. + + setResellerCustomerId(e.target.value)} + placeholder={"84726193"} + className={"min-w-[270px]"} + /> +
+
+
+ {isActive && ( + + + + )} +
+ ) : ( + <> + +
+
+
+ + + Enter the name of your customers company. + + setName(e.target.value)} + placeholder={"Acme Inc."} + className={"min-w-[270px]"} + /> +
+
+ + + The domain associated with this customer account. + + } + tabIndex={0} + value={domain} + error={domainInputError} + onChange={(e) => setDomain(e.target.value)} + placeholder={"acme-inc.com"} + className={"w-full"} + /> +
+
+ + + An optional identifier to easier map customers to your{" "} + internal systems. + + setResellerCustomerId(e.target.value)} + placeholder={"84726193"} + className={"min-w-[270px]"} + /> +
+
+
+ + )} + +
+ + {tab === "plan" ? ( + <> + Learn more about + + Pricing & Plans + + + + ) : ( + + )} + +
+
+ {!customer && ( + <> + + + + + + )} + {customer && ( + <> + + + + + + )} +
+
+
+ ); +}; + +const CustomerPlanTab = ({ customer }: { customer: DistributorCustomer }) => { + const { + plans, + isLoading, + currentPlan, + currency, + isSubscribing, + subscribe, + subscription, + } = useCustomerPlan({ accountId: customer.id }); + + return ( +
+ {(!plans || isLoading) && ( + <> + + + + )} + {!isLoading && + plans?.map((plan) => ( + subscribe(plan)} + key={plan.name} + /> + ))} +
+ ); +}; diff --git a/src/cloud/distributor/DistributorDocsLink.tsx b/src/cloud/distributor/DistributorDocsLink.tsx new file mode 100644 index 0000000..6c3850c --- /dev/null +++ b/src/cloud/distributor/DistributorDocsLink.tsx @@ -0,0 +1,18 @@ +import InlineLink from "@components/InlineLink"; +import { ExternalLinkIcon } from "lucide-react"; +import * as React from "react"; + +export const DistributorDocsLink = () => { + return ( + <> + Learn more about + + Customers + + + + ); +}; diff --git a/src/cloud/distributor/DistributorNavigation.tsx b/src/cloud/distributor/DistributorNavigation.tsx new file mode 100644 index 0000000..5108f52 --- /dev/null +++ b/src/cloud/distributor/DistributorNavigation.tsx @@ -0,0 +1,92 @@ +import SidebarItem from "@components/SidebarItem"; +import * as React from "react"; +import { useEffect, useMemo } from "react"; +import ActivityIcon from "@/assets/icons/ActivityIcon"; +import MSPIcon from "@/assets/icons/MSPIcon"; +import TeamIcon from "@/assets/icons/TeamIcon"; +import { useDistributor } from "@/cloud/distributor/contexts/DistributorProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { isNetBirdCloud } from "@utils/netbird"; +import { Role } from "@/interfaces/User"; +import PeerIcon from "@/assets/icons/PeerIcon"; + +export const DistributorNavigation = () => { + const { isActive } = useDistributor(); + const { isOwnerOrAdmin, loggedInUser } = useLoggedInUser(); + const { permission, isRestricted } = usePermissions(); + const isBillingAdmin = loggedInUser?.role === Role.BillingAdmin; + + const show = useMemo(() => { + return isActive && isNetBirdCloud(); + }, [isActive, isOwnerOrAdmin]); + + /** + * Hide the entire navigation for billing admins because they only have + * access to plans & billing settings and no other pages. + */ + useEffect(() => { + if (!show || !isBillingAdmin) return; + + const style = document.createElement("style"); + style.setAttribute("data-distributor-billing-admin", ""); + style.textContent = `body[data-distributor] [data-navigation], body[data-distributor] [data-navbar-colappse-toggle] { display: none; }`; + document.head.appendChild(style); + + return () => { + style.remove(); + }; + }, [show, isBillingAdmin]); + + if (!show) return; + + return ( +
+ {/* Show peers only for regular users — distributors don't have peer access, + but user role has no other pages to navigate to */} + } + label="Peers" + href={"/peers"} + visible={!isRestricted && loggedInUser?.role === Role.User} + /> + + } + visible={isOwnerOrAdmin} + label={"Customers"} + href={"/customers"} + exactPathMatch={true} + labelClassName={"-left-[1.5px] relative"} + /> + + } + visible={permission.users.read} + label={"Team"} + href={"/team"} + collapsible + > + + + + } + visible={permission.events.read} + label={"Audit Events"} + href={"/events/audit"} + exactPathMatch={true} + /> +
+ ); +}; diff --git a/src/cloud/distributor/DistributorSubscriptionModal.tsx b/src/cloud/distributor/DistributorSubscriptionModal.tsx new file mode 100644 index 0000000..3b1daca --- /dev/null +++ b/src/cloud/distributor/DistributorSubscriptionModal.tsx @@ -0,0 +1,109 @@ +import { InlineButtonLink } from "@components/InlineLink"; +import { Modal, ModalClose, ModalContent } from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import Paragraph from "@components/Paragraph"; +import { CreditCardIcon } from "lucide-react"; +import * as React from "react"; +import { useCustomerPlan } from "@/cloud/distributor/hooks/useCustomerPlan"; +import { PlanCard, PlanLoadingSkeleton } from "@/modules/billing/PlanCard"; + +type Props = { + open: boolean; + setOpen: (open: boolean) => void; + name: string; + accountId: string; +}; + +export const DistributorSubscriptionModal = ({ + open, + setOpen, + name, + accountId, +}: Props) => { + return ( + + + + ); +}; + +type ContentProps = { + name: string; + accountId: string; + setOpen: (open: boolean) => void; +}; + +const DistributorSubscriptionModalContent = ({ + name, + accountId, + setOpen, +}: ContentProps) => { + const { + plans, + isLoading, + currentPlan, + currency, + isSubscribing, + subscribe, + subscription, + } = useCustomerPlan({ accountId }); + + return ( + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + > + } + title={`NetBird Plan for ${name}`} + description={`Select the plan that best fits your customer's needs.`} + color={"netbird"} + /> +
+
+ {(!plans || isLoading) && ( + <> + + + + )} + {!isLoading && + plans?.map((plan) => ( + subscribe(plan).finally(() => setOpen(false))} + key={plan.name} + buttonText={{ + upgrade: "Continue with", + downgrade: "Downgrade to", + }} + /> + ))} +
+
+ + Haven't decided for a plan yet?{" "} + + + Continue with Trial + + + +
+
+
+ ); +}; diff --git a/src/cloud/distributor/DistributorTransferAccountModal.tsx b/src/cloud/distributor/DistributorTransferAccountModal.tsx new file mode 100644 index 0000000..bd30891 --- /dev/null +++ b/src/cloud/distributor/DistributorTransferAccountModal.tsx @@ -0,0 +1,127 @@ +import Button from "@components/Button"; +import { Modal, ModalContent } from "@components/modal/Modal"; +import { notify } from "@components/Notification"; +import Paragraph from "@components/Paragraph"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import { useApiCall } from "@utils/api"; +import { cn } from "@utils/helpers"; +import * as React from "react"; +import { useState } from "react"; +import { useSWRConfig } from "swr"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { useDialog } from "@/contexts/DialogProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { useAccount } from "@/modules/account/useAccount"; + +export const DistributorTransferAccountModal = () => { + const { mspInfo } = useMSP(); + const account = useAccount(); + const { confirm } = useDialog(); + const { isOwner } = useLoggedInUser(); + const { mutate } = useSWRConfig(); + const resellerRequest = useApiCall( + "/integrations/msp/reseller/msps", + true, + ); + + const hasResellerInvite = mspInfo?.reseller_status === "invited"; + + const [open, setOpen] = useState(true); + + const grantAccess = async () => { + const choice = await confirm({ + title: `Grant access to distributor?`, + description: `Are you sure you want to grant access? This action cannot be undone.`, + confirmText: "Grant Access", + cancelText: "Cancel", + type: "danger", + }); + if (!choice) return; + + notify({ + title: "Granting access to distributor", + description: "Access has been successfully granted.", + loadingMessage: "Granting access...", + promise: resellerRequest + .put( + { + value: "accept", + }, + `/${account?.id}/invite`, + ) + .finally(() => { + setOpen(false); + mutate("/integrations/msp/reseller"); + mutate("/integrations/msp"); + }), + }); + }; + + const deny = () => { + notify({ + title: "Access request denied", + description: "You have denied the distributor access request.", + loadingMessage: "Declining access...", + promise: resellerRequest + .put( + { + value: "decline", + }, + `/${account?.id}/invite`, + ) + .finally(() => { + setOpen(false); + mutate("/integrations/msp/reseller"); + mutate("/integrations/msp"); + }), + }); + }; + + if (!hasResellerInvite) return; + + return ( + isOwner && ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + > + +
+

+ A distributor is requesting access to your account +

+ + Granting access allows the distributor to manage billing and + subscription for your account. + +
+ + +
+
+
+
+ ) + ); +}; diff --git a/src/cloud/distributor/contexts/CustomersProvider.tsx b/src/cloud/distributor/contexts/CustomersProvider.tsx new file mode 100644 index 0000000..612dd35 --- /dev/null +++ b/src/cloud/distributor/contexts/CustomersProvider.tsx @@ -0,0 +1,213 @@ +import { notify } from "@components/Notification"; +import useFetchApi, { useApiCall } from "@utils/api"; +import * as React from "react"; +import { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import { DistributorAccountExistsModal } from "@/cloud/distributor/DistributorAccountExistsModal"; +import { DistributorCustomerModal } from "@/cloud/distributor/DistributorCustomerModal"; +import { DistributorSubscriptionModal } from "@/cloud/distributor/DistributorSubscriptionModal"; +import { + DistributorCustomer, + DistributorCustomerStatus, +} from "@/cloud/distributor/interfaces/Distributor"; +import { useDialog } from "@/contexts/DialogProvider"; + +type Props = { + children: React.ReactNode; +}; + +const CustomersContext = React.createContext( + {} as { + customers?: DistributorCustomer[]; + openCreateCustomerModal: () => void; + openEditCustomerModal: (customer: DistributorCustomer, initialTab?: string) => void; + openAccountExistsModal: (customer: DistributorCustomer) => void; + unlinkCustomer: (customer: DistributorCustomer) => Promise; + }, +); + +export const CustomersProvider = ({ children }: Props) => { + const { data: customers } = useFetchApi( + "/integrations/msp/reseller/msps", + ); + const { mutate } = useSWRConfig(); + const [customerModal, setCustomerModal] = useState(false); + const [subscriptionModal, setSubscriptionModal] = useState(false); + const [accountExistsModal, setAccountExistsModal] = useState(false); + const [currentCustomer, setCurrentCustomer] = useState(); + const [initialTab, setInitialTab] = useState(); + const { confirm } = useDialog(); + + const customerRequest = useApiCall( + `/integrations/msp/reseller/msps`, + true, + ); + + const unlinkCustomer = async (customer: DistributorCustomer) => { + const choice = await confirm({ + title: `Unlink '${customer.name}'?`, + description: + "Unlinking this customer will remove it from your distributor account. The account will continue to exist independently.", + confirmText: "Unlink", + cancelText: "Cancel", + type: "danger", + maxWidthClass: "max-w-[480px]", + }); + if (!choice) return; + + const promise = customerRequest.del({}, `/${customer.id}`).then(() => { + mutate("/integrations/msp/reseller/msps"); + }); + + notify({ + title: `Unlinking ${customer.name}`, + description: `Successfully unlinked ${customer.name} (${customer.domain})`, + loadingMessage: `Unlinking ${customer.name}...`, + promise, + }); + + return promise; + }; + + const sendAccountRequest = async (customer?: DistributorCustomer) => { + const c = customer || currentCustomer; + if (!c) return; + + const request = customerRequest + .post({}, `/${c.id}/invite`) + .then(() => { + mutate("/integrations/msp/reseller/msps"); + mutate("/integrations/msp/reseller"); + }) + .finally(() => { + setCustomerModal(false); + setAccountExistsModal(false); + setCurrentCustomer(undefined); + }); + + notify({ + title: `Request Account Access`, + description: "Request has been sent successfully.", + loadingMessage: "Sending request...", + promise: request, + }); + + return request; + }; + + const cancelAccountRequest = (customer?: DistributorCustomer) => { + const c = customer || currentCustomer; + if (!c) return; + return customerRequest + .del({}, `/${c.id}`) + .then(() => { + mutate("/integrations/msp/reseller/msps"); + mutate("/integrations/msp/reseller"); + }) + .finally(() => { + setCustomerModal(false); + setAccountExistsModal(false); + setCurrentCustomer(undefined); + }); + }; + + const onCustomerCreated = (customer: DistributorCustomer) => { + if (customer.status === DistributorCustomerStatus.Existing) { + setCurrentCustomer(customer); + setAccountExistsModal(true); + return; + } + if (customer.status === DistributorCustomerStatus.Invited) { + return; + } + setCurrentCustomer(customer); + setSubscriptionModal(true); + }; + + const contextData = useMemo(() => { + const openCreateCustomerModal = () => { + setCurrentCustomer(undefined); + setInitialTab(undefined); + setCustomerModal(true); + }; + + const openEditCustomerModal = ( + customer: DistributorCustomer, + initialTab?: string, + ) => { + setCurrentCustomer(customer); + setInitialTab(initialTab); + setCustomerModal(true); + }; + + const openAccountExistsModal = (customer: DistributorCustomer) => { + setCurrentCustomer(customer); + setAccountExistsModal(true); + }; + + return { + customers, + openCreateCustomerModal, + openEditCustomerModal, + openAccountExistsModal, + }; + }, [customers]); + + return ( + + { + if (!state) { + setCurrentCustomer(undefined); + setInitialTab(undefined); + } + setCustomerModal(state); + }} + customer={currentCustomer} + initialTab={initialTab} + onCreated={onCustomerCreated} + /> + + {subscriptionModal && currentCustomer && ( + { + if (!o) { + setCurrentCustomer(undefined); + setInitialTab(undefined); + } + setSubscriptionModal(o); + }} + name={currentCustomer.name} + accountId={currentCustomer.id} + /> + )} + + {accountExistsModal && currentCustomer && ( + + )} + + {children} + + ); +}; + +export const useCustomers = () => { + const context = React.useContext(CustomersContext); + if (context === undefined) { + throw new Error("useCustomers must be used within a CustomersProvider"); + } + return context; +}; diff --git a/src/cloud/distributor/contexts/DistributorProvider.tsx b/src/cloud/distributor/contexts/DistributorProvider.tsx new file mode 100644 index 0000000..7f5eb9f --- /dev/null +++ b/src/cloud/distributor/contexts/DistributorProvider.tsx @@ -0,0 +1,106 @@ +import useFetchApi from "@utils/api"; +import React, { useEffect, useMemo } from "react"; +import { Distributor } from "@/cloud/distributor/interfaces/Distributor"; + +type Props = { + children: React.ReactNode; +}; + +const DISTRIBUTOR_HIDDEN_NAV_ITEMS = [ + "/control-center", + "/setup-keys", + "/access-control", + "/networks", + "/network-routes", + "/reverse-proxy", + "/dns", + "/team", + "/events", + "/tenants", + "/peers", +]; + +const DISTRIBUTOR_HIDDEN_SETTINGS_TABS = [ + "networks", + "clients", + "groups", + "edr", +]; + +const DISTRIBUTOR_HIDDEN_AUTH_SETTINGS = [ + "peer-approval", + "peer-session-expiration", +]; + +const DistributorContext = React.createContext( + {} as { + distributorInfo?: Distributor; + isDistributorInfoLoading: boolean; + isActive: boolean; + }, +); + +export default function DistributorProvider({ children }: Readonly) { + const { + data: distributorInfo, + isLoading: isDistributorInfoLoading, + error, + } = useFetchApi("/integrations/msp/reseller", true); + + const isActive = useMemo(() => { + try { + if (isDistributorInfoLoading || distributorInfo === undefined || error) + return false; + if (!Object.hasOwn(distributorInfo, "activated_at")) return false; + return distributorInfo.activated_at !== ""; + } catch (err) { + return false; + } + }, [isDistributorInfoLoading, distributorInfo, error]); + + useEffect(() => { + if (isActive) { + document.body.setAttribute("data-distributor", ""); + + const style = document.createElement("style"); + style.setAttribute("data-distributor-styles", ""); + const navRules = DISTRIBUTOR_HIDDEN_NAV_ITEMS.map( + (href) => + `body[data-distributor] [data-nav-item="${href}"]:not([data-distributor-nav] *)`, + ); + const settingsRules = DISTRIBUTOR_HIDDEN_SETTINGS_TABS.map( + (tab) => `body[data-distributor] [data-settings-tab="${tab}"]`, + ); + const authRules = DISTRIBUTOR_HIDDEN_AUTH_SETTINGS.map( + (setting) => `body[data-distributor] [data-auth-setting="${setting}"]`, + ); + const hideRules = + [...navRules, ...settingsRules, ...authRules].join(",\n") + + " { display: none; }"; + const overrideRules = `body[data-distributor] [data-auth-setting="toggles"] { margin-top: 1rem; }`; + style.textContent = hideRules + "\n" + overrideRules; + document.head.appendChild(style); + + return () => { + document.body.removeAttribute("data-distributor"); + style.remove(); + }; + } + }, [isActive]); + + return ( + + {children} + + ); +} + +export function useDistributor() { + return React.useContext(DistributorContext); +} diff --git a/src/cloud/distributor/hooks/useCustomerPlan.ts b/src/cloud/distributor/hooks/useCustomerPlan.ts new file mode 100644 index 0000000..d90cc95 --- /dev/null +++ b/src/cloud/distributor/hooks/useCustomerPlan.ts @@ -0,0 +1,166 @@ +import useFetchApi, { useApiCall } from "@utils/api"; +import { notify } from "@components/Notification"; +import { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import { useTenantSubscription } from "@/cloud/msp/hooks/useTenantSubscription"; +import { useBilling } from "@/contexts/BillingProvider"; +import { AccountUsageStats } from "@/interfaces/AccountUsageStats"; +import { Plan } from "@/interfaces/Plan"; +import { PlanTier } from "@/interfaces/Subscription"; + +type Props = { + accountId: string; + withUsage?: boolean; +}; + +export const useCustomerPlan = ({ accountId, withUsage = false }: Props) => { + const { mutate } = useSWRConfig(); + const subscriptionRequest = useApiCall( + "/integrations/msp/reseller/msps", + true, + ); + const { + currency, + plans, + isLoading: isBillingLoading, + getCurrentPlanByPlanTier, + calculateEstimatedPrice, + } = useBilling(); + + // Usage stats + const { data: stats, isLoading: isStatsLoading } = + useFetchApi( + `/integrations/billing/usage?account=${accountId}`, + true, + false, + withUsage, + ); + + // Subscription status + const { + currentPlanTier, + subscription, + trialDaysRemaining, + isTrialExpired, + isSubscriptionLoading, + } = useTenantSubscription({ tenantId: accountId }); + + const teamAndBusinessPlans = plans?.filter( + (plan) => + plan.name.toLowerCase().includes("team") || + plan.name.toLowerCase().includes("business"), + ); + + const [isSubscribing, setIsSubscribing] = useState({ + team: false, + business: false, + }); + + const subscribe = async (plan: Plan) => { + if (!accountId) return; + if (!subscription) return; + + let name = plan?.name?.toLowerCase() || ""; + setIsSubscribing({ + team: name === PlanTier.TEAM, + business: name === PlanTier.BUSINESS, + }); + + const isOnFreeOrTrial = + subscription.plan_tier === PlanTier.FREE || + subscription.plan_tier === PlanTier.TRIAL; + + let price = plan.prices.find((price) => price.currency === currency); + + const promise = isOnFreeOrTrial + ? subscriptionRequest.post( + { priceID: price?.price_id }, + `/${accountId}/subscription`, + ) + : subscriptionRequest.put( + { priceID: price?.price_id }, + `/${accountId}/subscription`, + ); + + notify({ + title: `Subscription Plan`, + description: `Successfully subscribed to the ${plan.name} plan.`, + loadingMessage: `Subscribing to ${plan.name}...`, + promise, + }); + + return promise + .then(() => { + mutate(`/integrations/billing/subscription?account=${accountId}`); + }) + .finally(() => { + setIsSubscribing({ + team: false, + business: false, + }); + }); + }; + + const currentPlan = useMemo(() => { + if (!plans || !currentPlanTier) return; + return getCurrentPlanByPlanTier(currentPlanTier); + }, [plans, currentPlanTier, getCurrentPlanByPlanTier]); + + const currentPlanPrice = useMemo(() => { + return currentPlan?.prices.find( + (price) => price.price_id === subscription?.price_id, + ); + }, [currentPlan, subscription]); + + const estimatedPrice = useMemo(() => { + if (!currentPlan || !stats || !currency) return 0; + return calculateEstimatedPrice(currentPlan, currency, stats); + }, [currentPlan, stats, currency, calculateEstimatedPrice]); + + const isFreePlan = currentPlan + ? currentPlan.name.toLowerCase().includes(PlanTier.FREE) + : true; + + const isTrial = useMemo(() => { + if (isSubscriptionLoading && !subscription) return undefined; + if (subscription?.plan_tier === PlanTier.BUSINESS) return false; + if (subscription?.plan_tier === PlanTier.ENTERPRISE) return false; + if (subscription?.remaining_trial === undefined) return false; + return subscription.remaining_trial > 0; + }, [subscription, isSubscriptionLoading]); + + const maxPeersOfPlan = useMemo(() => { + const freeUsers = 0; + return ( + 100 + + Math.max( + currentPlan && !currentPlan.name.toLowerCase().includes(PlanTier.FREE) + ? ((stats?.active_users || 1) - freeUsers) * 10 + : 0, + 0, + ) + ); + }, [currentPlan, stats?.active_users]); + + const isLoading = isBillingLoading || isSubscriptionLoading || isStatsLoading; + + return { + teamAndBusinessPlans, + isSubscribing, + subscribe, + currentPlan, + subscription, + isLoading, + plans: teamAndBusinessPlans, + currency, + stats, + trialDaysRemaining, + currentPlanTier, + isTrialExpired, + estimatedPrice, + currentPlanPrice, + isFreePlan, + isTrial, + maxPeersOfPlan, + }; +}; diff --git a/src/cloud/distributor/interfaces/Distributor.ts b/src/cloud/distributor/interfaces/Distributor.ts new file mode 100644 index 0000000..e44b371 --- /dev/null +++ b/src/cloud/distributor/interfaces/Distributor.ts @@ -0,0 +1,27 @@ +import { Tenant } from "@/cloud/msp/interfaces/Tenant"; + +export interface Distributor { + activated_at: string; + domain: string; + name: string; + parent_owner_email: string; + parent_owner_name: string; +} + +export enum DistributorCustomerStatus { + Existing = "existing", + Invited = "invited", + Active = "active", +} + +export type DistributorCustomer = Pick< + Tenant, + "id" | "name" | "domain" | "activated_at" +> & { + owner_email: string; + has_reseller: boolean; + invited_at?: string; + reseller_customer_id?: string; + status: DistributorCustomerStatus; + tenant_number?: number; +}; diff --git a/src/cloud/distributor/table/CustomerActionCell.tsx b/src/cloud/distributor/table/CustomerActionCell.tsx new file mode 100644 index 0000000..5c7d696 --- /dev/null +++ b/src/cloud/distributor/table/CustomerActionCell.tsx @@ -0,0 +1,87 @@ +import Badge from "@components/Badge"; +import Button from "@components/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; +import FullTooltip from "@components/FullTooltip"; +import { + HelpCircle, + MoreVertical, + SquarePenIcon, + UnlinkIcon, +} from "lucide-react"; +import * as React from "react"; +import { useCustomers } from "@/cloud/distributor/contexts/CustomersProvider"; +import { + DistributorCustomer, + DistributorCustomerStatus, +} from "@/cloud/distributor/interfaces/Distributor"; + +type Props = { + customer: DistributorCustomer; +}; + +export const CustomerActionCell = ({ customer }: Props) => { + const { openEditCustomerModal, unlinkCustomer } = useCustomers(); + const isActive = customer.status === DistributorCustomerStatus.Active; + const isInvited = customer.status === DistributorCustomerStatus.Invited; + + return ( +
+ {isInvited && ( + + The customer account owner must log in to the dashboard to accept + or decline your invitation. +
+ } + > + + Pending invitation + + + + )} + + + + + { + e.stopPropagation(); + e.preventDefault(); + }} + > + + + + unlinkCustomer(customer)} + > +
+ + Unlink +
+
+
+
+
+ ); +}; diff --git a/src/cloud/distributor/table/CustomerNameCell.tsx b/src/cloud/distributor/table/CustomerNameCell.tsx new file mode 100644 index 0000000..cce8407 --- /dev/null +++ b/src/cloud/distributor/table/CustomerNameCell.tsx @@ -0,0 +1,77 @@ +import { cn, generateColorFromString } from "@utils/helpers"; +import { CircleAlertIcon, Clock } from "lucide-react"; +import * as React from "react"; +import { useCustomerPlan } from "@/cloud/distributor/hooks/useCustomerPlan"; +import { + DistributorCustomer, + DistributorCustomerStatus, +} from "@/cloud/distributor/interfaces/Distributor"; + +type Props = { + customer: DistributorCustomer; +}; + +export const CustomerNameCell = ({ customer }: Props) => { + const { isTrialExpired } = useCustomerPlan({ accountId: customer.id }); + const isActive = customer.status === DistributorCustomerStatus.Active; + + return ( +
+
+ {customer.name.charAt(0)} + +
+
+ + {customer.name} + + {customer.domain} +
+
+ ); +}; + +type AvatarBadgeProps = { + isActive: boolean; + isTrialExpired?: boolean; +}; + +const AvatarBadge = ({ + isActive, + isTrialExpired = false, +}: AvatarBadgeProps) => { + if (!isActive) { + return ( +
+ +
+ ); + } + if (isTrialExpired) + return ( +
+ +
+ ); +}; diff --git a/src/cloud/distributor/table/CustomerPlanCell.tsx b/src/cloud/distributor/table/CustomerPlanCell.tsx new file mode 100644 index 0000000..a288cb3 --- /dev/null +++ b/src/cloud/distributor/table/CustomerPlanCell.tsx @@ -0,0 +1,98 @@ +import Badge from "@components/Badge"; +import Button from "@components/Button"; +import { cn } from "@utils/helpers"; +import { CircleAlertIcon, CreditCardIcon } from "lucide-react"; +import * as React from "react"; +import Skeleton from "react-loading-skeleton"; +import { useCustomerPlan } from "@/cloud/distributor/hooks/useCustomerPlan"; +import { + DistributorCustomer, + DistributorCustomerStatus, +} from "@/cloud/distributor/interfaces/Distributor"; +import { PlanTier } from "@/interfaces/Subscription"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; + +type Props = { + customer: DistributorCustomer; + onUpgrade?: () => void; +}; + +export const CustomerPlanCell = ({ customer, onUpgrade }: Props) => { + const { currentPlanTier, isLoading, trialDaysRemaining, isTrialExpired } = + useCustomerPlan({ accountId: customer.id }); + + const isInvited = customer.status === DistributorCustomerStatus.Invited; + if (isInvited) return ; + + if (isLoading || !currentPlanTier) + return ; + + if (isTrialExpired) + return ( +
+ + + Trial Expired + + {onUpgrade && ( + + )} +
+ ); + + const isActive = customer.status === DistributorCustomerStatus.Active; + if (!isActive) return ; + + return ( +
+ + {currentPlanTier === PlanTier.TRIAL && ( + + )} +
+ ); +}; + +type RemainingTrialDaysProps = { + days?: number; +}; + +const RemainingTrialDays = ({ days }: RemainingTrialDaysProps) => { + if (days === undefined) return null; + if (days === 1) return ` (${days} day left)`; + if (days === 0) return ` (Trial has expired)`; + + return ` (${days} days left)`; +}; + +const CurrentPlan = ({ plan }: { plan: PlanTier }) => { + return ( + <> + + {plan == PlanTier.BUSINESS && "Business"} + {plan == PlanTier.TEAM && "Team"} + {plan == PlanTier.FREE && "Free"} + {plan == PlanTier.TRIAL && "Free Trial"} + + ); +}; diff --git a/src/cloud/distributor/table/CustomerTenantsCell.tsx b/src/cloud/distributor/table/CustomerTenantsCell.tsx new file mode 100644 index 0000000..bd386a2 --- /dev/null +++ b/src/cloud/distributor/table/CustomerTenantsCell.tsx @@ -0,0 +1,33 @@ +import Badge from "@components/Badge"; +import { cn } from "@utils/helpers"; +import { UsersIcon } from "lucide-react"; +import * as React from "react"; +import { + DistributorCustomer, + DistributorCustomerStatus, +} from "@/cloud/distributor/interfaces/Distributor"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; + +type Props = { + customer: DistributorCustomer; +}; + +export const CustomerTenantsCell = ({ customer }: Props) => { + const isActive = customer.status === DistributorCustomerStatus.Active; + const isInvited = customer.status === DistributorCustomerStatus.Invited; + + if (isInvited || !isActive) return ; + + return ( +
+ + +
+ + {customer.tenant_number || 0} + +
+
+
+ ); +}; diff --git a/src/cloud/distributor/table/DistributorCustomersTable.tsx b/src/cloud/distributor/table/DistributorCustomersTable.tsx new file mode 100644 index 0000000..a73f77a --- /dev/null +++ b/src/cloud/distributor/table/DistributorCustomersTable.tsx @@ -0,0 +1,203 @@ +import Button from "@components/Button"; +import CopyToClipboardText from "@components/CopyToClipboardText"; +import SquareIcon from "@components/SquareIcon"; +import { DataTable } from "@components/table/DataTable"; +import DataTableHeader from "@components/table/DataTableHeader"; +import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; +import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; +import GetStartedTest from "@components/ui/GetStartedTest"; +import { ColumnDef, SortingState } from "@tanstack/react-table"; +import { PlusCircle, ReceiptTextIcon } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import React from "react"; +import { useSWRConfig } from "swr"; +import MSPIcon from "@/assets/icons/MSPIcon"; +import { DistributorDocsLink } from "@/cloud/distributor/DistributorDocsLink"; +import { useCustomers } from "@/cloud/distributor/contexts/CustomersProvider"; +import { DistributorCustomer } from "@/cloud/distributor/interfaces/Distributor"; +import { CustomerActionCell } from "@/cloud/distributor/table/CustomerActionCell"; +import { CustomerNameCell } from "@/cloud/distributor/table/CustomerNameCell"; +import { CustomerPlanCell } from "@/cloud/distributor/table/CustomerPlanCell"; +import { CustomerTenantsCell } from "@/cloud/distributor/table/CustomerTenantsCell"; +import { useLocalStorage } from "@/hooks/useLocalStorage"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; + +const CustomerPlanCellWithUpgrade = ({ + customer, +}: { + customer: DistributorCustomer; +}) => { + const { openEditCustomerModal } = useCustomers(); + return ( + openEditCustomerModal(customer, "plan")} + /> + ); +}; + +const CustomersTableColumns: ColumnDef[] = [ + { + id: "name", + accessorKey: "name", + header: ({ column }) => ( + Customer + ), + cell: ({ row }) => , + }, + { + id: "domain", + accessorKey: "domain", + }, + { + id: "reseller_customer_id", + accessorKey: "reseller_customer_id", + header: ({ column }) => ( + Customer ID + ), + cell: ({ row }) => + row.original.reseller_customer_id ? ( + + + {row.original.reseller_customer_id} + + + ) : ( + + ), + }, + { + id: "plan", + accessorKey: "plan", + header: ({ column }) => ( + Plan + ), + cell: ({ row }) => , + }, + { + id: "tenants", + accessorKey: "tenants", + header: ({ column }) => ( + Tenants + ), + cell: ({ row }) => , + }, + { + id: "actions", + accessorKey: "id", + header: "", + cell: ({ row }) => , + }, +]; + +type Props = { + customers?: DistributorCustomer[]; + isLoading: boolean; + headingTarget?: HTMLHeadingElement | null; +}; + +export default function DistributorCustomersTable({ + customers, + isLoading, + headingTarget, +}: Readonly) { + const { mutate } = useSWRConfig(); + const path = usePathname(); + const router = useRouter(); + + const refreshCustomers = () => + mutate( + (key) => + typeof key === "string" && + [ + "/integrations/msp/reseller/msps", + "/integrations/billing/subscription?account=", + "/integrations/msp/tenants?account=", + ].some((prefix) => key.startsWith(prefix)), + ); + + const [sorting, setSorting] = useLocalStorage( + "netbird-table-sort" + path, + [ + { + id: "name", + desc: false, + }, + ], + ); + + return ( + } + color={"gray"} + size={"large"} + /> + } + title={"Add New Customer"} + description={ + "It looks like you don't have any customers yet. Add a new customer to get started." + } + button={} + learnMore={} + /> + } + rightSide={() => ( +
+ {customers && customers.length > 0 && ( +
+ + +
+ )} +
+ )} + > + {(table) => { + return ( + <> + + + + ); + }} +
+ ); +} + +const AddCustomerButton = () => { + const { openCreateCustomerModal } = useCustomers(); + return ( + + ); +}; diff --git a/src/cloud/distributor/useDistributorRedirect.tsx b/src/cloud/distributor/useDistributorRedirect.tsx new file mode 100644 index 0000000..a7e32ab --- /dev/null +++ b/src/cloud/distributor/useDistributorRedirect.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useDistributor } from "@/cloud/distributor/contexts/DistributorProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { Role } from "@/interfaces/User"; + +const useDistributorRedirect = () => { + const router = useRouter(); + const pathname = usePathname(); + const { isOwnerOrAdmin, loggedInUser } = useLoggedInUser(); + + const { isActive: isDistributor, isDistributorInfoLoading } = + useDistributor(); + + const isRegularUser = loggedInUser?.role === Role.User; + const isBillingAdmin = loggedInUser?.role === Role.BillingAdmin; + + // The Peers area now spans /peers, /peers/users and /peers/servers; we + // gate the redirect on any of them so a distributor that lands on a + // sub-page still gets bounced to their proper home. + const onPeersArea = + pathname === "/peers" || + pathname === "/peers/users" || + pathname === "/peers/servers"; + + React.useEffect(() => { + if (!isDistributor || !onPeersArea) return; + + if (isOwnerOrAdmin) { + router.replace("/customers"); + } else if (isBillingAdmin) { + router.replace("/settings?tab=plans-and-billing"); + } else if (!isRegularUser) { + router.replace("/team/users"); + } + }, [isDistributor, onPeersArea, router, isOwnerOrAdmin, isRegularUser, isBillingAdmin]); + + // Regular users stay on the Peers area, everyone else redirects + const isRedirecting = isDistributor && onPeersArea && !isRegularUser; + + return { + isLoading: isDistributorInfoLoading || isRedirecting, + }; +}; +export default useDistributorRedirect; diff --git a/src/cloud/edr/PeerDisapprovalReason.tsx b/src/cloud/edr/PeerDisapprovalReason.tsx new file mode 100644 index 0000000..681d560 --- /dev/null +++ b/src/cloud/edr/PeerDisapprovalReason.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; +import { Peer } from "@/interfaces/Peer"; + +export const PeerDisapprovalReason = ({ peer }: { peer: Peer }) => { + if (!peer?.disapproval_reason) return null; + + return ( +
+ Reason: {peer?.disapproval_reason} +
+ ); +}; diff --git a/src/cloud/edr/useBypass.tsx b/src/cloud/edr/useBypass.tsx new file mode 100644 index 0000000..25f73bf --- /dev/null +++ b/src/cloud/edr/useBypass.tsx @@ -0,0 +1,60 @@ +import useFetchApi, { useApiCall } from "@utils/api"; +import { useSWRConfig } from "swr"; +import { usePermissions } from "@/contexts/PermissionsProvider"; + +export interface BypassResponse { + peer_id: string; +} + +const BYPASSED_PATH = "/peers/edr/bypassed"; + +export const useBypassedPeers = () => { + const { data, isLoading, mutate } = useFetchApi( + BYPASSED_PATH, + true, + ); + + // The endpoint can resolve to a non-array (e.g. an error body) on + // self-hosted/unlicensed deployments; coerce so `.map` never throws. + const bypassedPeers = Array.isArray(data) ? data : []; + const bypassedPeerIds = new Set(bypassedPeers.map((p) => p.peer_id)); + + const isBypassed = (peerId: string) => bypassedPeerIds.has(peerId); + + return { + bypassedPeers, + bypassedPeerIds, + isBypassed, + isLoading, + mutate, + }; +}; + +export const useBypass = () => { + const { permission } = usePermissions(); + const { mutate } = useSWRConfig(); + const api = useApiCall("/peers", true); + + const bypassCompliance = async (peerId: string) => { + const result = await api.post({}, `/${peerId}/edr/bypass`); + await mutate("/peers"); + await mutate("/groups"); + await mutate(BYPASSED_PATH); + return result; + }; + + const revokeBypass = async (peerId: string) => { + await api.del({}, `/${peerId}/edr/bypass`); + await mutate("/peers"); + await mutate("/groups"); + await mutate(BYPASSED_PATH); + }; + + const canBypass = permission?.edr?.update && permission?.peers?.update; + + return { + bypassCompliance, + revokeBypass, + canBypass, + }; +}; diff --git a/src/cloud/invoices/InvoicesTab.tsx b/src/cloud/invoices/InvoicesTab.tsx new file mode 100644 index 0000000..85fed26 --- /dev/null +++ b/src/cloud/invoices/InvoicesTab.tsx @@ -0,0 +1,113 @@ +import Breadcrumbs from "@components/Breadcrumbs"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable, { + SkeletonTableHeader, +} from "@components/skeletons/SkeletonTable"; +import { VerticalTabs } from "@components/VerticalTabs"; +import { usePortalElement } from "@hooks/usePortalElement"; +import * as Tabs from "@radix-ui/react-tabs"; +import useFetchApi from "@utils/api"; +import { isNetBirdCloud } from "@utils/netbird"; +import { ReceiptTextIcon } from "lucide-react"; +import * as React from "react"; +import { Suspense } from "react"; +import SettingsIcon from "@/assets/icons/SettingsIcon"; +import { useDistributor } from "@/cloud/distributor/contexts/DistributorProvider"; +import InvoicesTable from "@/cloud/invoices/table/InvoicesTable"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { Invoice } from "@/cloud/msp/interfaces/Invoice"; +import { usePermissions } from "@/contexts/PermissionsProvider"; + +export const InvoicesTab = () => { + const { permission } = usePermissions(); + + const { isAccountWithMSPParent } = useMSP(); + if (isAccountWithMSPParent) return; + + return permission?.billing?.update && ; +}; + +export const InvoicesTabTrigger = () => { + const { permission } = usePermissions(); + + const { isAccountWithMSPParent } = useMSP(); + if (isAccountWithMSPParent) return; + + return ( + permission?.billing?.update && + isNetBirdCloud() && ( + + + Invoices + + ) + ); +}; + +const InvoicesTabContent = () => { + const { isActive: isDistributor } = useDistributor(); + const apiPath = isDistributor + ? "/integrations/msp/reseller/invoices" + : "/integrations/billing/invoices"; + + const { data: invoices, isLoading } = useFetchApi( + apiPath, + true, + false, + true, + { + shouldRetryOnError: false, + }, + ); + + const { ref: headingRef, portalTarget } = + usePortalElement(); + + return ( + +
+ + } + /> + } + active + /> + + +
+
+
+

Invoices

+ View and export all your available invoices +
+
+ + +
+ +
+
+ } + > + + +
+
+ + ); +}; diff --git a/src/cloud/invoices/table/InvoicesActionCell.tsx b/src/cloud/invoices/table/InvoicesActionCell.tsx new file mode 100644 index 0000000..a06ebc9 --- /dev/null +++ b/src/cloud/invoices/table/InvoicesActionCell.tsx @@ -0,0 +1,109 @@ +import Button from "@components/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; +import { notify } from "@components/Notification"; +import { useApiCall } from "@utils/api"; +import { DownloadIcon, MoreVertical } from "lucide-react"; +import React from "react"; +import { useDistributor } from "@/cloud/distributor/contexts/DistributorProvider"; +import { Invoice, InvoicePDF } from "@/cloud/msp/interfaces/Invoice"; + +type Props = { + invoice: Invoice; +}; + +export default function InvoicesActionCell({ invoice }: Readonly) { + const { isActive: isDistributor } = useDistributor(); + const apiRequestPath = isDistributor + ? "/integrations/msp/reseller/invoices" + : "/integrations/billing/invoices"; + + const pdfInvoiceRequest = useApiCall(apiRequestPath, true); + + const csvInvoiceRequest = useApiCall(apiRequestPath, true, { + blob: true, + }); + + const downloadCSV = async () => { + let promise = csvInvoiceRequest + .get(`/${invoice?.id}/csv`) + .then((blob) => { + let fileName = `${invoice.id}.csv`; + + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + }) + .catch((error) => { + throw error; + }); + + notify({ + title: `Download Invoice (CSV)`, + description: `Downloading ${invoice.id}.csv...`, + loadingMessage: `Getting invoice for this billing period...`, + promise, + }); + return promise; + }; + + const redirectToStripe = async () => { + let promise = pdfInvoiceRequest.get(`/${invoice?.id}/pdf`); + notify({ + title: `Download Invoice (PDF)`, + description: `Redirecting to Stripe to download the invoice...`, + loadingMessage: `Getting invoice for this billing period...`, + promise, + }); + return promise; + }; + + return ( +
+ + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + + { + redirectToStripe().then((r) => { + if (r?.url) window.open(r.url, "_blank"); + }); + }} + > +
+ + Download as PDF +
+
+ + +
+ + Download as CSV +
+
+
+
+
+ ); +} diff --git a/src/cloud/invoices/table/InvoicesPeriodCell.tsx b/src/cloud/invoices/table/InvoicesPeriodCell.tsx new file mode 100644 index 0000000..365b05a --- /dev/null +++ b/src/cloud/invoices/table/InvoicesPeriodCell.tsx @@ -0,0 +1,20 @@ +import dayjs from "dayjs"; +import { ReceiptTextIcon } from "lucide-react"; +import React from "react"; +import { Invoice } from "@/cloud/msp/interfaces/Invoice"; + +type Props = { + invoice: Invoice; +}; + +export default function InvoicesPeriodCell({ invoice }: Readonly) { + const { period_end } = invoice; + const end = period_end && dayjs(period_end).format("MMM DD, YYYY"); + + return ( +
+ + {`${end}`} +
+ ); +} diff --git a/src/cloud/invoices/table/InvoicesTable.tsx b/src/cloud/invoices/table/InvoicesTable.tsx new file mode 100644 index 0000000..2bf6cf1 --- /dev/null +++ b/src/cloud/invoices/table/InvoicesTable.tsx @@ -0,0 +1,119 @@ +import Card from "@components/Card"; +import { DataTable } from "@components/table/DataTable"; +import DataTableHeader from "@components/table/DataTableHeader"; +import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; +import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; +import NoResults from "@components/ui/NoResults"; +import { ColumnDef, SortingState } from "@tanstack/react-table"; +import { ReceiptTextIcon } from "lucide-react"; +import * as React from "react"; +import { useState } from "react"; +import { useSWRConfig } from "swr"; +import { useDistributor } from "@/cloud/distributor/contexts/DistributorProvider"; +import InvoicesActionCell from "@/cloud/invoices/table/InvoicesActionCell"; +import InvoicesPeriodCell from "@/cloud/invoices/table/InvoicesPeriodCell"; +import InvoicesTypeCell from "@/cloud/invoices/table/InvoicesTypeCell"; +import { Invoice } from "@/cloud/msp/interfaces/Invoice"; + +type Props = { + invoices?: Invoice[]; + isLoading: boolean; + headingTarget?: HTMLHeadingElement | null; +}; + +const InvoicesColumns: ColumnDef[] = [ + { + header: ({ column }) => { + return Date; + }, + accessorKey: "period_start", + cell: ({ row }) => , + }, + { + accessorKey: "period_end", + }, + { + header: ({ column }) => { + return Type; + }, + accessorKey: "type", + cell: ({ row }) => , + }, + { + accessorKey: "id", + header: () => null, + sortingFn: "text", + cell: ({ row }) => , + }, +]; + +export default function InvoicesTable({ + invoices, + isLoading, + headingTarget, +}: Readonly) { + const { isActive: isDistributor } = useDistributor(); + const apiRequestPath = isDistributor + ? "/integrations/msp/reseller/invoices" + : "/integrations/billing/invoices"; + const { mutate } = useSWRConfig(); + + const [sorting, setSorting] = useState([ + { + id: "period_end", + desc: true, + }, + ]); + + return ( + } + /> + } + columnVisibility={{ + period_end: false, + }} + paginationPaddingClassName={"px-0 pt-8"} + > + {(table) => ( + <> + + { + mutate(apiRequestPath).then(); + }} + /> + + )} + + ); +} diff --git a/src/cloud/invoices/table/InvoicesTypeCell.tsx b/src/cloud/invoices/table/InvoicesTypeCell.tsx new file mode 100644 index 0000000..56b55b6 --- /dev/null +++ b/src/cloud/invoices/table/InvoicesTypeCell.tsx @@ -0,0 +1,28 @@ +import { UserIcon, UsersIcon } from "lucide-react"; +import React from "react"; +import { useDistributor } from "@/cloud/distributor/contexts/DistributorProvider"; +import { Invoice } from "@/cloud/msp/interfaces/Invoice"; + +type Props = { + invoice: Invoice; +}; + +export default function InvoicesTypeCell({ invoice }: Readonly) { + const { isActive: isDistributor } = useDistributor(); + const { type } = invoice; + return ( +
+ {type == "account" ? ( + <> + + Account + + ) : ( + <> + + {isDistributor ? "Customers" : "Tenants"} + + )} +
+ ); +} diff --git a/src/cloud/mfa/AccountMFACard.tsx b/src/cloud/mfa/AccountMFACard.tsx new file mode 100644 index 0000000..9543bb8 --- /dev/null +++ b/src/cloud/mfa/AccountMFACard.tsx @@ -0,0 +1,75 @@ +import { IconCircleFilled } from "@tabler/icons-react"; +import useFetchApi from "@utils/api"; +import loadConfig from "@utils/config"; +import { cn } from "@utils/helpers"; +import { ShieldCheckIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { useMemo } from "react"; +import { AccountMFA } from "@/cloud/mfa/AccountMFASettings"; +import { isNetBirdCloud } from "@utils/netbird"; + +const config = loadConfig(); + +export const AccountMfaCard = () => { + const { data: accountMfa } = useFetchApi( + "/service/mfa", + true, + true, + !!config.authServiceUrl, + { + origin: config.authServiceUrl, + }, + ); + + const enabled = useMemo(() => { + return accountMfa?.mfa || false; + }, [accountMfa]); + + const router = useRouter(); + + return ( + isNetBirdCloud() && ( + + ) + ); +}; diff --git a/src/cloud/mfa/AccountMFAInfoModal.tsx b/src/cloud/mfa/AccountMFAInfoModal.tsx new file mode 100644 index 0000000..0aca440 --- /dev/null +++ b/src/cloud/mfa/AccountMFAInfoModal.tsx @@ -0,0 +1,55 @@ +import Button from "@components/Button"; +import { Modal, ModalContent } from "@components/modal/Modal"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import * as React from "react"; + +type Props = { + open: boolean; + setOpen: React.Dispatch>; + onConfirm: () => void; + onCancel: () => void; +}; + +export const AccountMFAInfoModal = ({ + open, + setOpen, + onCancel, + onConfirm, +}: Props) => { + return ( + + + +
+
+ You may not need NetBird MFA +
+
+ {`Your`} + + {" "} + SSO Provider + + {` may already have MFA enabled. Enabling this setting could result in duplicated MFA checks.`} +
+
+ + +
+
+
+
+ ); +}; diff --git a/src/cloud/mfa/AccountMFASettings.tsx b/src/cloud/mfa/AccountMFASettings.tsx new file mode 100644 index 0000000..581061c --- /dev/null +++ b/src/cloud/mfa/AccountMFASettings.tsx @@ -0,0 +1,208 @@ +import { Checkbox } from "@components/Checkbox"; +import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import { notify } from "@components/Notification"; +import useFetchApi, { useApiCall } from "@utils/api"; +import loadConfig from "@utils/config"; +import { cn } from "@utils/helpers"; +import { Loader2Icon, ShieldCheckIcon } from "lucide-react"; +import * as React from "react"; +import { useEffect, useState } from "react"; +import { AccountMFAInfoModal } from "@/cloud/mfa/AccountMFAInfoModal"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; + +const config = loadConfig(); + +export interface AccountMFA { + id: string; + mfa: boolean; + mfaRememberBrowser: boolean; +} + +export const AccountMFASettings = () => { + const { permission } = usePermissions(); + const { + data: accountMfa, + isLoading: isDataLoading, + error, + mutate, + } = useFetchApi( + "/service/mfa", + true, + true, + !!config.authServiceUrl, + { + origin: config.authServiceUrl, + }, + ); + const mfaRequest = useApiCall("/service/mfa", true, { + origin: config.authServiceUrl, + }).post; + + const [isLoading, setIsLoading] = useState(false); + const [showLoader, setShowLoader] = useState(false); + const [infoModal, setInfoModal] = useState(false); + + const { loggedInUser } = useLoggedInUser(); + const isUserNamePasswordAuth = loggedInUser?.id?.startsWith("auth0|"); + + const [pendingUpdate, setPendingUpdate] = useState<{ + enable: boolean; + rememberBrowser?: boolean; + } | null>(null); + + useEffect(() => { + if (isLoading) { + setShowLoader(true); + const timer = setTimeout(() => setShowLoader(false), 2000); + return () => clearTimeout(timer); + } + }, [isLoading]); + + const update = async ( + enable: boolean, + rememberBrowser?: boolean, + skipModal?: boolean, + ) => { + if (!accountMfa) return; + + if (!isUserNamePasswordAuth && enable && !skipModal) { + setPendingUpdate({ enable, rememberBrowser }); + setInfoModal(true); + return; + } + + const timer = setTimeout(() => setIsLoading(true), 800); + + const rememberBrowserDescription = `Option to remember browser is now ${ + rememberBrowser ? "enabled" : "disabled" + }`; + + const mfaDescription = `MFA is now ${ + enable ? "enabled" : "disabled" + } for your account`; + + notify({ + title: "Multi-Factor Authentication (MFA)", + description: + rememberBrowser != undefined + ? rememberBrowserDescription + : mfaDescription, + promise: mfaRequest({ + mfa: enable, + mfa_remember_browser: rememberBrowser, + }) + .then(() => { + mutate( + { + ...accountMfa, + mfa: enable, + mfaRememberBrowser: rememberBrowser, + }, + { + populateCache: true, + revalidate: false, + }, + ); + }) + .finally(() => { + setIsLoading(false); + clearTimeout(timer); + }), + loadingMessage: "Updating MFA settings...", + }); + }; + const handleModalConfirm = () => { + if (pendingUpdate) { + update(pendingUpdate.enable, pendingUpdate.rememberBrowser, true).then(); + } + setInfoModal(false); + setPendingUpdate(null); + }; + + const handleModalCancel = () => { + setInfoModal(false); + setPendingUpdate(null); + }; + + return ( + <> + +
+
+
+ + Updating MFA settings... +
+
+ { + update(enable).then(); + }} + label={ + <> + + Multi-Factor Authentication (MFA) + + } + helpText={ + <> + Enable NetBird MFA if not configured in your IdP.
+ This setting is global and applies to all users. + + } + /> + +
+ + ); +}; diff --git a/src/cloud/mfa/UserMFAListItem.tsx b/src/cloud/mfa/UserMFAListItem.tsx new file mode 100644 index 0000000..427510a --- /dev/null +++ b/src/cloud/mfa/UserMFAListItem.tsx @@ -0,0 +1,205 @@ +import Button from "@components/Button"; +import Card from "@components/Card"; +import FullTooltip from "@components/FullTooltip"; +import InlineLink from "@components/InlineLink"; +import { notify } from "@components/Notification"; +import useFetchApi, { useApiCall } from "@utils/api"; +import loadConfig from "@utils/config"; +import { cn } from "@utils/helpers"; +import { isNetBirdCloud } from "@utils/netbird"; +import { + ArrowUpRightIcon, + ExternalLinkIcon, + HelpCircle, + Loader2Icon, + RotateCcw, + ShieldCheckIcon, +} from "lucide-react"; +import * as React from "react"; +import { useState } from "react"; +import Skeleton from "react-loading-skeleton"; +import { useSWRConfig } from "swr"; +import { AccountMFA } from "@/cloud/mfa/AccountMFASettings"; +import { useDialog } from "@/contexts/DialogProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; + +const config = loadConfig(); + +type Props = { + userId: string; +}; + +export const UserMfaListItem = ({ userId }: Props) => { + const { permission } = usePermissions(); + return permission.settings.update && isNetBirdCloud() ? ( + + ) : null; +}; + +interface UserAuthMethods { + type: string; + confirmed: boolean; +} + +const ListItem = ({ userId }: Props) => { + const { data: userMfa, isLoading: isUserMfaLoading } = useFetchApi< + UserAuthMethods[] + >(`/service/mfa/${userId}`, true, false, !!config.authServiceUrl, { + origin: config.authServiceUrl, + }); + + const { data: accountMfa, isLoading: isAccountMfaLoading } = + useFetchApi( + `/service/mfa`, + true, + true, + !!config.authServiceUrl, + { + origin: config.authServiceUrl, + }, + ); + + const hasAuthMethods = !!(userMfa && userMfa.length >= 1); + const isLoading = isUserMfaLoading || isAccountMfaLoading; + + return ( + + + NetBird MFA + + NetBird MFA is primarily intended for users who log in with + email and password. You may not need NetBird MFA if your SSO + provider (e.g., Google, Microsoft) already has MFA enabled.{" "} + + Learn more + + + + } + > + + + + } + value={ + isLoading ? ( +
+ +
+ ) : ( + + ) + } + /> + ); +}; + +const MFAStatus = ({ + enabled, + hasAuthMethods, + userId, +}: { + enabled: boolean; + hasAuthMethods: boolean; + userId: string; +}) => { + return ( +
+ {hasAuthMethods && enabled && } + + {enabled ? ( +
+ {hasAuthMethods ? ( + <> + {" "} + Active + + ) : ( + <> + {" "} + Not Enrolled + + )} +
+ ) : ( +
+ + Activate + + +
+ )} +
+ ); +}; + +const ResetMFAButton = ({ userId }: Props) => { + const mfaDeleteRequest = useApiCall(`/service/mfa/${userId}`, true, { + origin: config.authServiceUrl, + }).del; + const { mutate } = useSWRConfig(); + const { confirm } = useDialog(); + const [isLoading, setIsLoading] = useState(false); + + const resetMFA = async () => { + const choice = await confirm({ + title: `Reset Multi-factor Authentication?`, + description: + "Are you sure you want to reset the current MFA methods for this user? The user will be prompted to set up MFA again on their next login.", + confirmText: "Confirm", + cancelText: "Cancel", + type: "warning", + }); + if (!choice) return; + + setIsLoading(true); + notify({ + title: "Multi-factor authentication (MFA)", + description: "MFA settings have been reset", + loadingMessage: "Resetting MFA for user...", + promise: mfaDeleteRequest({}).finally(() => + mutate(`/service/mfa/${userId}`).then(() => setIsLoading(false)), + ), + }); + }; + + return ( + + ); +}; diff --git a/src/cloud/msp/MSPAccountExistsModal.tsx b/src/cloud/msp/MSPAccountExistsModal.tsx new file mode 100644 index 0000000..84f3fe1 --- /dev/null +++ b/src/cloud/msp/MSPAccountExistsModal.tsx @@ -0,0 +1,81 @@ +import Button from "@components/Button"; +import { Callout } from "@components/Callout"; +import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import { GlobeIcon } from "lucide-react"; +import * as React from "react"; +import { Tenant } from "@/cloud/msp/interfaces/Tenant"; + +type Props = { + open: boolean; + setOpen: React.Dispatch>; + tenant: Tenant; + onAccept: (t: Tenant) => void; + onCancel: (t: Tenant) => void; +}; + +export const MSPAccountExistsModal = ({ + open, + setOpen, + tenant, + onAccept, + onCancel, +}: Props) => { + return ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + > + +
+
+ + {tenant?.domain} +
+
+ This NetBird account already
+ exists in our system +
+
+ To manage the account{" "} + {tenant?.domain}, you must first + request access from the account owner. +
+
+ + The account owner must log in to the dashboard to accept or decline + your request. Please inform them after you have requested access. + +
+ + + + + +
+
+ ); +}; diff --git a/src/cloud/msp/MSPDomainVerificationModal.tsx b/src/cloud/msp/MSPDomainVerificationModal.tsx new file mode 100644 index 0000000..9a10fd9 --- /dev/null +++ b/src/cloud/msp/MSPDomainVerificationModal.tsx @@ -0,0 +1,140 @@ +import Button from "@components/Button"; +import Card from "@components/Card"; +import InlineLink from "@components/InlineLink"; +import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import Paragraph from "@components/Paragraph"; +import Steps from "@components/Steps"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import { Mark } from "@components/ui/Mark"; +import { cn } from "@utils/helpers"; +import { ExternalLinkIcon, GlobeIcon } from "lucide-react"; +import * as React from "react"; +import { useState } from "react"; +import { useTenants } from "@/cloud/msp/contexts/TenantsProvider"; +import { Tenant } from "@/cloud/msp/interfaces/Tenant"; + +type Props = { + open: boolean; + setOpen: React.Dispatch>; + tenant: Tenant; + token: string; + onSuccess: () => void; + onCancel: () => void; +}; + +export const MSPDomainVerificationModal = ({ + open, + setOpen, + tenant, + token, + onSuccess, + onCancel, +}: Props) => { + const { verifyDomain } = useTenants(); + const [isLoading, setIsLoading] = useState(false); + + const domain = tenant.domain; + + const verify = async () => { + setIsLoading(true); + verifyDomain(tenant, false) + .then(() => onSuccess()) + .finally(() => setIsLoading(false)); + }; + + return ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + > + + } + title={"Verify Domain Ownership"} + description={domain} + color={"netbird"} + /> +
+ + +

+ Sign in to your domain name provider (e.g. cloudflare.com or + godaddy.com) +

+
+ +

+ Copy the TXT record below and add it to your DNS + configuration for {domain} +

+
+
+ + + + + + + + + { + "Note: DNS changes may take some time to apply. If NetBird doesn't find the record immediately, please wait a day and try again." + } + + +
+ If you do not have access to your DNS configuration, you can also + verify your domain by sending us an email to{" "} + + {" "} + support@netbird.io + + . The email should be sent from the domain you are trying to verify. +
+
+ +
+ + Learn more about + + Domain Verification + + + +
+
+ + +
+
+
+
+ ); +}; diff --git a/src/cloud/msp/MSPNavigationItem.tsx b/src/cloud/msp/MSPNavigationItem.tsx new file mode 100644 index 0000000..c7b5279 --- /dev/null +++ b/src/cloud/msp/MSPNavigationItem.tsx @@ -0,0 +1,34 @@ +import SidebarItem from "@components/SidebarItem"; +import { isNetBirdCloud } from "@utils/netbird"; +import * as React from "react"; +import { useMemo } from "react"; +import MSPIcon from "@/assets/icons/MSPIcon"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; + +export const MSPNavigationItem = () => { + const { isActive, isMSPInMSPContext } = useMSP(); + const { isOwnerOrAdmin } = useLoggedInUser(); + const { permission } = usePermissions(); + + const showNavigationItem = useMemo(() => { + if (!isActive) return false; + return isMSPInMSPContext && isOwnerOrAdmin; + }, [isActive, isMSPInMSPContext, isOwnerOrAdmin]); + + if (!showNavigationItem) return; + + return ( + isNetBirdCloud() && ( + } + visible={permission?.tenants?.read} + label={
Tenants
} + href={"/tenants"} + exactPathMatch={true} + labelClassName={"-left-[1.5px] relative"} + /> + ) + ); +}; diff --git a/src/cloud/msp/MSPSubscriptionModal.tsx b/src/cloud/msp/MSPSubscriptionModal.tsx new file mode 100644 index 0000000..a4a64a8 --- /dev/null +++ b/src/cloud/msp/MSPSubscriptionModal.tsx @@ -0,0 +1,98 @@ +import { InlineButtonLink } from "@components/InlineLink"; +import { Modal, ModalClose, ModalContent } from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import Paragraph from "@components/Paragraph"; +import { CreditCardIcon } from "lucide-react"; +import * as React from "react"; +import { useTenantPlan } from "@/cloud/msp/hooks/useTenantPlan"; +import { Tenant } from "@/cloud/msp/interfaces/Tenant"; +import { PlanCard, PlanLoadingSkeleton } from "@/modules/billing/PlanCard"; + +type Props = { + open: boolean; + setOpen: (open: boolean) => void; + tenant: Tenant; +}; + +export const MSPSubscriptionModal = ({ open, setOpen, tenant }: Props) => { + return ( + + + + ); +}; + +type SubscriptionModalContentProps = { + tenant: Tenant; + setOpen: (open: boolean) => void; +}; + +const MSPSubscriptionModalContent = ({ + tenant, + setOpen, +}: SubscriptionModalContentProps) => { + const { + plans, + isLoading, + currentPlan, + currency, + isSubscribing, + subscribe, + subscription, + } = useTenantPlan({ tenant }); + + return ( + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + > + } + title={`NetBird Plan for ${tenant.name}`} + description={"Select the plan that best fits your tenant's needs."} + color={"netbird"} + /> +
+
+ {(!plans || isLoading) && ( + <> + + + + )} + {!isLoading && + plans?.map((plan) => { + return ( + subscribe(plan).finally(() => setOpen(false))} + key={plan.name} + buttonText={{ + upgrade: "Continue with", + downgrade: "Downgrade to", + }} + /> + ); + })} +
+
+ + Haven’t decided for a plan yet?{" "} + + + Continue with Trial + + + +
+
+
+ ); +}; diff --git a/src/cloud/msp/MSPTenantDocsLink.tsx b/src/cloud/msp/MSPTenantDocsLink.tsx new file mode 100644 index 0000000..51aeae2 --- /dev/null +++ b/src/cloud/msp/MSPTenantDocsLink.tsx @@ -0,0 +1,18 @@ +import InlineLink from "@components/InlineLink"; +import { ExternalLinkIcon } from "lucide-react"; +import * as React from "react"; + +export const MSPTenantDocsLink = () => { + return ( + <> + Learn more about + + MSP Portal + + + + ); +}; diff --git a/src/cloud/msp/MSPTenantModal.tsx b/src/cloud/msp/MSPTenantModal.tsx new file mode 100644 index 0000000..93291ed --- /dev/null +++ b/src/cloud/msp/MSPTenantModal.tsx @@ -0,0 +1,368 @@ +import Button from "@components/Button"; +import Card from "@components/Card"; +import HelpText from "@components/HelpText"; +import InlineLink from "@components/InlineLink"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import { notify } from "@components/Notification"; +import Paragraph from "@components/Paragraph"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; +import { useHasChanges } from "@hooks/useHasChanges"; +import { useApiCall } from "@utils/api"; +import { cn, validator } from "@utils/helpers"; +import { + ExternalLinkIcon, + GlobeIcon, + LockIcon, + PlusCircle, + ShieldCheckIcon, + Text, + UserIcon, +} from "lucide-react"; +import * as React from "react"; +import { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import { useTenants } from "@/cloud/msp/contexts/TenantsProvider"; +import { + Tenant, + TenantDNSResponse, + TenantGroup, + TenantStatus, +} from "@/cloud/msp/interfaces/Tenant"; +import { MSPTenantDocsLink } from "@/cloud/msp/MSPTenantDocsLink"; +import { MSPTenantPermissionsTab } from "@/cloud/msp/MSPTenantPermissionsTab"; +import { + MSPTenantPlanTab, + MSPTenantPlanTabTrigger, +} from "@/cloud/msp/MSPTenantPlanTab"; +import { Role } from "@/interfaces/User"; +import useGroupHelper from "@/modules/groups/useGroupHelper"; + +type Props = { + open: boolean; + setOpen: (open: boolean) => void; + tenant?: Tenant; + initialTab?: string; +}; + +export const MSPTenantModal = ({ + open, + setOpen, + tenant, + initialTab, +}: Props) => { + return ( + + + + ); +}; + +const ModalWidth = { + general: "max-w-xl", + permissions: "max-w-[660px]", + plan: "max-w-3xl", +} as Record; + +const MspAccountModalContent = ({ setOpen, tenant, initialTab }: Props) => { + const { mutate } = useSWRConfig(); + const { openDomainVerificationModal, verifyDomain, openAccountExistsModal } = + useTenants(); + const tenantRequest = useApiCall( + "/integrations/msp/tenants", + true, + ); + + const [tab, setTab] = useState(initialTab || "general"); + + const [name, setName] = useState(tenant?.name || ""); + const [domain, setDomain] = useState(tenant?.domain || ""); + + const initialGroupIds = (tenant?.groups?.map((g) => g.id) as string[]) || []; + const [groups, setGroups, { save: saveGroups }] = useGroupHelper({ + initial: initialGroupIds, + }); + + const [tenantGroups, setTenantGroups] = useState( + tenant?.groups || [], + ); + + const domainInputError = useMemo(() => { + if (domain === "") return ""; + if (!validator.isValidDomain(domain)) { + return "Please enter a valid domain, e.g. netbird.io"; + } + return ""; + }, [domain]); + + const createTenant = async () => { + const savedGroups = await saveGroups(); + const newTenantGroups = savedGroups.map((group) => { + return { + id: group.id, + role: + tenantGroups?.find( + (tg) => tg.name === group.name || tg?.id === group?.id, + )?.role ?? Role.Admin, + } as TenantGroup; + }); + + notify({ + title: `Add ${domain} account`, + description: "The tenant account has been created successfully.", + preventSuccessToast: true, + promise: tenantRequest + .post({ name, domain, groups: newTenantGroups }) + .then((res) => { + const t = res as Tenant; + if (t?.status === TenantStatus.Existing) { + openAccountExistsModal(t); + } else { + mutate("/integrations/msp/tenants"); + mutate("/integrations/msp"); + mutate("/integrations/msp/switcher"); + openDomainVerificationModal(res as Tenant, res.dns_challenge); + } + }), + }); + }; + + const updateTenant = async () => { + if (!tenant) return; + const savedGroups = await saveGroups(); + const newTenantGroups = savedGroups.map((group) => { + return { + id: group.id, + role: + tenantGroups?.find( + (tg) => tg.name === group.name || tg?.id === group?.id, + )?.role ?? Role.Admin, + } as TenantGroup; + }); + + notify({ + title: `Update ${tenant?.domain} account`, + description: "The tenant account has been updated successfully.", + promise: tenantRequest + .put({ name, groups: newTenantGroups }, `/${tenant.id}`) + .then(() => refreshAndClose()), + }); + }; + + const refreshAndClose = () => { + mutate("/integrations/msp/tenants"); + mutate("/integrations/msp"); + mutate("/integrations/msp/switcher"); + setOpen(false); + }; + + const canContinue = domainInputError === "" && name !== "" && domain !== ""; + const { hasChanges } = useHasChanges([name, groups, tenantGroups]); + const isActive = tenant?.activated_at !== undefined; + + return ( + + } + title={tenant ? `Update Tenant` : "Add Tenant"} + description={ + tenant + ? `${tenant.name} (${tenant.domain})` + : "Add a new tenant account to your organization." + } + color={"netbird"} + /> + + + + + General + + + + Permissions + + {tenant && } + + +
+
+ + + Set an easily recognizable name for the tenant. + + + setName(e.target.value)} + placeholder={"Acme Inc."} + className={"min-w-[270px]"} + /> +
+
+ + {!tenant ? ( + Enter the primary domain of the tenant. + ) : ( + Primary domain of the tenant. + )} + + {tenant ? ( + +
+ + {domain} + + + + {isActive ? "Ownership Verified" : "Pending Verification"} + +
+
+ {!isActive && ( + + )} +
+
+ ) : ( + } + autoFocus={false} + tabIndex={0} + value={domain} + error={domainInputError} + data-testid={"name"} + disabled={tenant} + onChange={(e) => setDomain(e.target.value)} + placeholder={"acme.de"} + className={"w-full"} + /> + )} +
+
+
+ + + + {tenant && } +
+ +
+ + {tab === "plan" ? ( + <> + Learn more about + + Pricing & Plans + + + + ) : ( + + )} + +
+
+ {tab === "general" && !tenant && ( + <> + + + + + + )} + {tab === "permissions" && !tenant && ( + <> + + + + )} + {tenant && ( + <> + + + + {tab !== "plan" && ( + + )} + + )} +
+
+
+ ); +}; diff --git a/src/cloud/msp/MSPTenantPermissionsTab.tsx b/src/cloud/msp/MSPTenantPermissionsTab.tsx new file mode 100644 index 0000000..ddcfa2e --- /dev/null +++ b/src/cloud/msp/MSPTenantPermissionsTab.tsx @@ -0,0 +1,178 @@ +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import { Label } from "@components/Label"; +import { PeerGroupSelector } from "@components/PeerGroupSelector"; +import GroupBadge from "@components/ui/GroupBadge"; +import { cn } from "@utils/helpers"; +import { + ArrowRightIcon, + ChevronDownIcon, + MinusCircleIcon, + PlusIcon, +} from "lucide-react"; +import * as React from "react"; +import { TenantGroup } from "@/cloud/msp/interfaces/Tenant"; +import { useUsers } from "@/contexts/UsersProvider"; +import { Group } from "@/interfaces/Group"; +import { Role } from "@/interfaces/User"; +import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack"; +import { UserRoles, UserRoleSelector } from "@/modules/users/UserRoleSelector"; + +type Props = { + groups: Group[]; + onGroupsChange: React.Dispatch>; + tenantGroups?: TenantGroup[]; + setTenantGroups: React.Dispatch>; +}; + +export const MSPTenantPermissionsTab = ({ + groups, + onGroupsChange, + tenantGroups, + setTenantGroups, +}: Props) => { + const { users } = useUsers(); + + const handleGroupRemove = (group: Group) => { + onGroupsChange((prev) => prev.filter((g) => g.name !== group.name)); + setTenantGroups((prev) => prev.filter((g) => g.name !== group.name)); + }; + + const handleListItemChange = (group: Group, role: Role) => { + const tenantGroup = tenantGroups?.find((g) => g.id === group.id); + if (tenantGroup) { + setTenantGroups((prev) => + prev.map((g) => (g.id === group.id ? { ...g, role } : g)), + ); + } else { + setTenantGroups((prev) => [ + ...(prev || []), + { id: group.id, name: group.name, role }, + ]); + } + }; + + return ( +
+
+
+ + + Add user groups to grant them access to this tenant. + +
+
+ + + Add Group + + } + onChange={onGroupsChange} + values={groups} + hideAllGroup={true} + popoverWidth={450} + showResourceCounter={false} + align={"end"} + users={users} + /> +
+
+ + {groups?.length > 0 && ( +
+ {groups.map((group) => { + const tenantGroup = tenantGroups?.find((g) => g.id === group.id); + + return ( + handleGroupRemove(group)} + onChange={(role) => handleListItemChange(group, role)} + /> + ); + })} +
+ )} +
+ ); +}; + +type ListItemProps = { + group: Group; + onRemove?: () => void; + role?: Role; + onChange: (role: Role) => void; +}; + +const ListItem = ({ + group, + onRemove, + onChange, + role = Role.Admin, +}: ListItemProps) => { + const { users } = useUsers(); + const selectedRole = UserRoles.find((r) => r.value === role) || UserRoles[0]; + + const usersOfGroup = + users?.filter((user) => user.auto_groups.includes(group.id as string)) || + []; + + return ( +
+
+ + + +
+
+ + + {selectedRole?.name} + + + } + /> + +
+
+ ); +}; diff --git a/src/cloud/msp/MSPTenantPlanTab.tsx b/src/cloud/msp/MSPTenantPlanTab.tsx new file mode 100644 index 0000000..a8cdbdb --- /dev/null +++ b/src/cloud/msp/MSPTenantPlanTab.tsx @@ -0,0 +1,71 @@ +import { TabsContent, TabsTrigger } from "@components/Tabs"; +import { CreditCardIcon } from "lucide-react"; +import * as React from "react"; +import { useTenantPlan } from "@/cloud/msp/hooks/useTenantPlan"; +import { Tenant, TenantStatus } from "@/cloud/msp/interfaces/Tenant"; +import { PlanCard, PlanLoadingSkeleton } from "@/modules/billing/PlanCard"; + +type Props = { + tenant: Tenant; +}; + +export const MSPTenantPlanTab = ({ tenant }: Props) => { + const { + plans, + isLoading, + currentPlan, + currency, + isSubscribing, + subscribe, + subscription, + } = useTenantPlan({ tenant }); + + const isActive = tenant.status === TenantStatus.Active; + if (!isActive) return null; + + return ( + +
+ {(!plans || isLoading) && ( + <> + + + + )} + {!isLoading && + plans?.map((plan) => { + return ( + subscribe(plan)} + key={plan.name} + /> + ); + })} +
+
+ ); +}; + +export const MSPTenantPlanTabTrigger = ({ tenant }: Props) => { + if (!tenant) return null; + + const isActive = tenant.status === TenantStatus.Active; + if (!isActive) return null; + + return ( + + + Plan + + ); +}; diff --git a/src/cloud/msp/MSPTenantsSwitcher.tsx b/src/cloud/msp/MSPTenantsSwitcher.tsx new file mode 100644 index 0000000..385b9f2 --- /dev/null +++ b/src/cloud/msp/MSPTenantsSwitcher.tsx @@ -0,0 +1,328 @@ +import Badge from "@components/Badge"; +import Button from "@components/Button"; +import { DropdownInfoText } from "@components/DropdownInfoText"; +import { DropdownInput } from "@components/DropdownInput"; +import FullTooltip from "@components/FullTooltip"; +import InlineLink from "@components/InlineLink"; +import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover"; +import { SmallBadge } from "@components/ui/SmallBadge"; +import TruncatedText from "@components/ui/TruncatedText"; +import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList"; +import { useSearch } from "@hooks/useSearch"; +import { cn, generateColorFromString } from "@utils/helpers"; +import { ArrowUpRightIcon, ChevronsUpDown, CircleHelp } from "lucide-react"; +import * as React from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { useTenantSubscription } from "@/cloud/msp/hooks/useTenantSubscription"; +import { TenantListItem } from "@/cloud/msp/interfaces/Tenant"; +import { useApplicationContext } from "@/contexts/ApplicationProvider"; +import { useBilling } from "@/contexts/BillingProvider"; + +export const MSPTenantsSwitcher = () => { + const { isTrial, isFreePlan } = useBilling(); + + const { + isActive, + tenantListItems, + isTenantListItemsLoading, + currentAccount, + mspAccount, + isMspInfoLoading, + isMSPInMSPContext, + isMSPInTenantContext, + } = useMSP(); + + const showSwitcher = useMemo(() => { + if (!mspAccount) return false; + if (!isActive && isMSPInMSPContext) return false; + if (isTenantListItemsLoading || isMspInfoLoading) return false; + if (isMSPInTenantContext) return true; + return isMSPInMSPContext && !isTrial && !isFreePlan; + }, [ + isActive, + isFreePlan, + isMSPInMSPContext, + isMSPInTenantContext, + isMspInfoLoading, + isTenantListItemsLoading, + isTrial, + mspAccount, + ]); + + if (!showSwitcher) return; + + return ( + mspAccount && + tenantListItems && + tenantListItems?.length >= 1 && ( + + ) + ); +}; + +const searchPredicate = (item: TenantListItem, query: string) => { + const lowerCaseQuery = query.toLowerCase(); + if (item.name.toLowerCase().includes(lowerCaseQuery)) return true; + return item.domain.toLowerCase().includes(lowerCaseQuery); +}; + +type Props = { + currentAccount?: TenantListItem; + mspAccount: TenantListItem; + tenants: TenantListItem[]; +}; + +const TenantDropdown = ({ tenants, currentAccount, mspAccount }: Props) => { + const { loginAs } = useMSP(); + const [open, setOpen] = useState(false); + + const [filteredItems, search, setSearch] = useSearch( + tenants, + searchPredicate, + { filter: true, debounce: 150 }, + ); + + const tenantListItems = useMemo(() => { + // Remove current account from the list + let items = + filteredItems?.filter((i) => { + return i.id !== currentAccount?.id; + }) ?? []; + + // Add MSP account to the top of the list if user is in tenant context + if (currentAccount) { + items = [mspAccount, ...items]; + } + + return items; + }, [filteredItems, currentAccount, mspAccount]); + + return ( + { + if (!isOpen) { + setTimeout(() => { + setSearch(""); + }, 100); + } + setOpen(isOpen); + }} + > +
+ + + +
+ +
+ + + {tenantListItems.length == 0 && search == "" && ( +
+ + {"Seems like you don't have any customers."} + +
+ )} + + {tenantListItems.length == 0 && search != "" && ( +
+ + There are no customers matching your search. Try another search + term. + +
+ )} + + {tenantListItems.length > 0 && ( + { + return; + }} + estimatedItemHeight={56} + maxHeight={340} + renderItem={(option, selected) => { + return ( + { + loginAs(option); + setOpen(false); + }} + selected={selected} + /> + ); + }} + /> + )} +
+
+
+ ); +}; + +type TenantItemProps = { + tenant: TenantListItem; + isMSP?: boolean; + onSelect?: (item: TenantListItem) => void; + selected?: boolean; + allowTrialExpiredInfo?: boolean; +}; + +const TenantItem = ({ + tenant, + isMSP, + selected, + onSelect, + allowTrialExpiredInfo = false, +}: TenantItemProps) => { + const allowFetchSubscription = isMSP === undefined || !isMSP; + const { isTrialExpired, isSubscriptionLoading } = useTenantSubscription({ + tenantId: tenant.id, + allowFetch: allowFetchSubscription, + }); + + const { globalApiParams } = useApplicationContext(); + const isInTenantContext = globalApiParams?.account; + + const firstChar = tenant.name.charAt(0); + const color = generateColorFromString(tenant.name); + + const handleSelect = () => { + if (isTrialExpired || isSubscriptionLoading) return; + onSelect?.(tenant); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (selected && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + handleSelect(); + } + }; + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [selected]); + + const showTrialExpiredInfo = + isTrialExpired && !isSubscriptionLoading && allowFetchSubscription; + + return ( +
+
+ {firstChar} +
+
+ + + {isMSP && ( +
+ +
+ )} +
+ + + +
+ + {showTrialExpiredInfo && allowTrialExpiredInfo && ( +
+ + Trial for this tenant has expired. Please upgrade the plan to + continue using the tenant.{" "} + {!isInTenantContext && ( + + Go to Tenants + + + )} +
+ } + > + + + Trial Expired + + +
+ )} + + ); +}; diff --git a/src/cloud/msp/MSPTenantsTable.tsx b/src/cloud/msp/MSPTenantsTable.tsx new file mode 100644 index 0000000..0c1ebdc --- /dev/null +++ b/src/cloud/msp/MSPTenantsTable.tsx @@ -0,0 +1,291 @@ +import Button from "@components/Button"; +import FullTooltip from "@components/FullTooltip"; +import SquareIcon from "@components/SquareIcon"; +import { DataTable } from "@components/table/DataTable"; +import DataTableHeader from "@components/table/DataTableHeader"; +import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; +import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; +import GetStartedTest from "@components/ui/GetStartedTest"; +import { ColumnDef, SortingState, Table } from "@tanstack/react-table"; +import { + CreditCardIcon, + HelpCircle, + PlusCircle, + ReceiptTextIcon, +} from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import React, { Fragment } from "react"; +import { useSWRConfig } from "swr"; +import MSPIcon from "@/assets/icons/MSPIcon"; +import { useTenants } from "@/cloud/msp/contexts/TenantsProvider"; +import { Tenant } from "@/cloud/msp/interfaces/Tenant"; +import { MSPTenantDocsLink } from "@/cloud/msp/MSPTenantDocsLink"; +import { TenantActionCell } from "@/cloud/msp/table/TenantActionCell"; +import { TenantGroupsCell } from "@/cloud/msp/table/TenantGroupsCell"; +import { TenantNameCell } from "@/cloud/msp/table/TenantNameCell"; +import { TenantPeersCell } from "@/cloud/msp/table/TenantPeersCell"; +import { TenantPlanCell } from "@/cloud/msp/table/TenantPlanCell"; +import { TenantPlanCostCell } from "@/cloud/msp/table/TenantPlanCostCell"; +import { TenantUsersCell } from "@/cloud/msp/table/TenantUsersCell"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { LockedFeatureInfoCard } from "@/modules/billing/locked-feature/LockedFeatureInfoCard"; +import { LockedFeatureOverlay } from "@/modules/billing/locked-feature/LockedFeatureOverlay"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; + +const TenantsTableColumns: ColumnDef[] = [ + { + id: "name", + accessorKey: "name", + header: ({ column }) => ( + Tenant + ), + cell: ({ row }) => , + }, + { + id: "domain", + accessorKey: "domain", + }, + { + id: "plan", + accessorKey: "plan", + header: ({ column }) => ( + Plan + ), + cell: ({ row }) => , + }, + { + id: "users", + accessorKey: "users", + header: ({ column }) => ( + Users + ), + cell: ({ row }) => , + }, + { + id: "peers", + accessorKey: "peers", + header: ({ column }) => ( + Peers + ), + cell: ({ row }) => , + }, + { + id: "cost", + accessorKey: "id", + header: ({ column }) => ( + + Est. Cost / Month + + The estimated price is calculated based on the number of active + users and active peers. + + } + interactive={false} + > + + + + ), + cell: ({ row }) => , + }, + { + id: "groups", + accessorKey: "peers", + header: ({ column }) => ( + Permission Groups + ), + cell: ({ row }) => , + }, + { + id: "actions", + accessorKey: "id", + header: "", + cell: ({ row }) => , + }, + { + id: "created_at", + accessorKey: "created_at", + }, +]; + +type Props = { + tenants?: Tenant[]; + isLoading: boolean; + headingTarget?: HTMLHeadingElement | null; +}; + +export default function MSPTenantsTable({ + tenants, + isLoading, + headingTarget, +}: Readonly) { + const { hasReseller } = useMSP(); + const { mutate } = useSWRConfig(); + const { permission } = usePermissions(); + const path = usePathname(); + const router = useRouter(); + + // Default sorting state of the table + const [sorting, setSorting] = useLocalStorage( + "netbird-table-sort" + path, + [ + { + id: "created_at", + desc: true, + }, + { + id: "name", + desc: false, + }, + { + id: "domain", + desc: false, + }, + ], + ); + + const refreshSubscriptions = (table: Table) => { + const visibleTenants = table + ?.getPaginationRowModel() + .rows.map((row) => row.original); + + const tenantIds = visibleTenants.map((tenant) => tenant.id); + if (tenantIds.length === 0) return; + + tenantIds.forEach((id) => { + mutate(`/integrations/billing/usage?account=${id}`); + mutate(`/integrations/billing/subscription?account=${id}`); + }); + }; + + return ( + <> + + + 0 ? 35 : 60} + feature={"MSP"} + > + } + color={"gray"} + size={"large"} + /> + } + title={"Add New Tenant"} + description={ + "It looks like you don't have any tenants yet. Add a new tenant to get started." + } + button={} + learnMore={} + /> + } + rightSide={() => ( +
+ {tenants && tenants.length > 0 && ( +
+ {permission?.billing?.update && !hasReseller && ( + + )} + + +
+ )} +
+ )} + > + {(table) => { + return ( + <> + + + { + mutate("/integrations/msp/tenants"); + mutate("/integrations/msp/switcher"); + mutate("/integrations/billing/invoice"); + mutate("/groups"); + mutate("/users"); + refreshSubscriptions(table); + }} + /> + + ); + }} +
+
+ + ); +} + +const AddTenantButton = () => { + const { openCreateTenantModal } = useTenants(); + return ( + + ); +}; + +const TotalCost = () => { + return ( +
+
+ +
+
+ + Total Cost / Month + + + $5,260 + +
+
+ ); +}; diff --git a/src/cloud/msp/MSPTransferAccountModal.tsx b/src/cloud/msp/MSPTransferAccountModal.tsx new file mode 100644 index 0000000..147c319 --- /dev/null +++ b/src/cloud/msp/MSPTransferAccountModal.tsx @@ -0,0 +1,193 @@ +import Button from "@components/Button"; +import { Modal, ModalContent } from "@components/modal/Modal"; +import { notify } from "@components/Notification"; +import Paragraph from "@components/Paragraph"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import { useApiCall } from "@utils/api"; +import { cn, generateColorFromString } from "@utils/helpers"; +import { + ArrowRightLeft, + MonitorSmartphoneIcon, + SettingsIcon, + UserIcon, +} from "lucide-react"; +import Image from "next/image"; +import * as React from "react"; +import { useState } from "react"; +import { useSWRConfig } from "swr"; +import netBirdLogo from "@/assets/netbird.svg"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { TenantDNSResponse, TenantStatus } from "@/cloud/msp/interfaces/Tenant"; +import { useDialog } from "@/contexts/DialogProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; + +export const MSPTransferAccountModal = () => { + const { mspInfo } = useMSP(); + const { confirm } = useDialog(); + const { isOwner } = useLoggedInUser(); + const { mutate } = useSWRConfig(); + const tenantRequest = useApiCall( + "/integrations/msp/tenants", + true, + ); + + const hasParent = mspInfo && Object.hasOwn(mspInfo, "parent_name"); + const isInvited = mspInfo?.status === TenantStatus.Invited; + const tenantId = mspInfo?.id; + + const firstChar = mspInfo?.parent_name?.charAt(0); + const color = generateColorFromString(mspInfo?.parent_name); + const [open, setOpen] = useState(true); + + const grantAccess = async () => { + const choice = await confirm({ + title: `Granting access to ${mspInfo?.parent_domain}?`, + description: `Are you sure you want to grant access? This action cannot be undone.`, + confirmText: "Grant Access", + cancelText: "Cancel", + type: "danger", + }); + if (!choice) return; + + notify({ + title: `Granting access to ${mspInfo?.parent_domain}`, + description: "Access has been successfully granted.", + loadingMessage: "Granting access...", + promise: tenantRequest + .put( + { + value: "accept", + }, + `/${tenantId}/invite`, + ) + .finally(() => { + setOpen(false); + mutate("/integrations/msp"); + }), + }); + }; + + const deny = () => { + notify({ + title: "Access request denied", + description: "You have denied the access request.", + loadingMessage: "Declining access...", + promise: tenantRequest + .put( + { + value: "decline", + }, + `/${tenantId}/invite`, + ) + .finally(() => { + setOpen(false); + mutate("/integrations/msp"); + }), + }); + }; + + const showModal = hasParent && isInvited; + if (!showModal) return; + + return ( + isOwner && ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + > + +
+
+
+ {firstChar} +
+
+
+ +
+
+ {"NetBird"} +
+
+
+

+ {mspInfo?.parent_owner_name + ? mspInfo?.parent_owner_name + " from " + : ""} + {mspInfo?.parent_name || mspInfo?.name} is requesting + access to your account +

+ + A Managed Service Provider (MSP) is requesting access to manage + your account and all of its associated resources. + +
+
+ Please review the request carefully before proceeding. Granting + them access will allow them to: +
+
    +
  • + + Manage your account, settings and configurations +
  • +
  • + + Manage all devices and associated resources +
  • +
  • + + Manage all users, groups and permissions +
  • +
+
+
+ + +
+
+
+
+ ) + ); +}; diff --git a/src/cloud/msp/MSPTrialExpiredModal.tsx b/src/cloud/msp/MSPTrialExpiredModal.tsx new file mode 100644 index 0000000..bb31a4d --- /dev/null +++ b/src/cloud/msp/MSPTrialExpiredModal.tsx @@ -0,0 +1,143 @@ +import Button from "@components/Button"; +import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import loadConfig from "@utils/config"; +import { ClockAlertIcon, LogOutIcon, MailIcon } from "lucide-react"; +import * as React from "react"; +import { useMemo } from "react"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { TenantListItem } from "@/cloud/msp/interfaces/Tenant"; +import { useBilling } from "@/contexts/BillingProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { PlanTier } from "@/interfaces/Subscription"; + +const config = loadConfig(); + +export const MSPTrialExpiredModal = () => { + const { logout } = useLoggedInUser(); + + const { + mspAccount, + isMspInfoLoading, + currentAccount, + isMSPInTenantContext, + loginAs, + isAccountWithMSPParent, + mspInfo, + } = useMSP(); + + const { + subscription, + isLoading: isBillingLoading, + trialDaysRemaining, + } = useBilling(); + + const isTrialExpired = useMemo(() => { + if (!subscription) return false; + if ( + subscription.plan_tier === PlanTier.TRIAL || + subscription.plan_tier === PlanTier.FREE + ) { + return trialDaysRemaining === 0; + } + return false; + }, [subscription, trialDaysRemaining]); + + // Do not show the modal if MSP info and billing is still loading + if (isMspInfoLoading || isBillingLoading) return; + + // Do not show the modal if there is no tenant context, or no MSP account, or trial is not expired + if (!isAccountWithMSPParent || !mspAccount || !isTrialExpired) return; + + const mailToEmail = + mspInfo?.reseller_status === "active" + ? "support@netbird.io" + : mspInfo?.parent_owner_email || "support@netbird.io"; + + return ( + + + +
+ +
+ {isMSPInTenantContext + ? "The 14-Day Trial has expired!" + : "Your 14-Day Trial has expired!"} +
+ {isMSPInTenantContext ? ( +
+ + has reached the end of the free trial period. To continue using + NetBird, please upgrade the plan for this tenant. +
+ ) : mspInfo?.reseller_status === "active" ? ( +
+ Your account has reached the end of the free trial period.
{" "} + To continue using NetBird, please contact your distributor. +
+ ) : ( +
+ Your account has reached the end of the free trial period. To + continue using NetBird, please contact your account administrator{" "} + . +
+ )} +
+ + {isMSPInTenantContext ? ( + + + + ) : ( + + + + + + + )} +
+
+ ); +}; + +const MSPName = () => { + const { mspContact, mspAccount } = useMSP(); + if (!mspAccount) return ""; + return {mspContact}; +}; + +const TenantName = ({ + currentAccount, +}: { + currentAccount?: TenantListItem; +}) => { + if (!currentAccount) return "This tenant "; + return ( + + {currentAccount?.name} ({currentAccount?.domain}){" "} + + ); +}; diff --git a/src/cloud/msp/MSPUnlinkModal.tsx b/src/cloud/msp/MSPUnlinkModal.tsx new file mode 100644 index 0000000..eee3a04 --- /dev/null +++ b/src/cloud/msp/MSPUnlinkModal.tsx @@ -0,0 +1,112 @@ +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import InlineLink from "@components/InlineLink"; +import { Label } from "@components/Label"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import Paragraph from "@components/Paragraph"; +import Separator from "@components/Separator"; +import { UserSelector } from "@components/UserSelector"; +import useFetchApi from "@utils/api"; +import { cn } from "@utils/helpers"; +import { ExternalLinkIcon, InfoIcon, UnlinkIcon } from "lucide-react"; +import * as React from "react"; +import { useState } from "react"; +import { useTenants } from "@/cloud/msp/contexts/TenantsProvider"; +import { Tenant } from "@/cloud/msp/interfaces/Tenant"; +import { User } from "@/interfaces/User"; + +type Props = { + open: boolean; + setOpen: (open: boolean) => void; + tenant: Tenant; +}; + +export const MSPUnlinkModal = ({ open, setOpen, tenant }: Props) => { + const { unlinkTenant } = useTenants(); + const { data: users } = useFetchApi( + `/users?service_user=false&account=${tenant.id}`, + true, + false, + true, + { + ignoreGlobalParams: true, + }, + ); + + const [selectedUser, setSelectedUser] = useState(); + + return ( + tenant && ( + + + } + title={"Unlink Tenant"} + description={`${tenant.name} (${tenant.domain})`} + color={"yellow"} + /> + +
+ + + In order to unlink this tenant, you need to assign a new owner. + + +
+ +
+ After unlinking, the existing subscription for this tenant will + be canceled, and the new owner will need to set up their own + billing information. +
+
+
+ +
+ + Learn more about + + Unlinking Tenants + + + +
+
+ + + + + +
+
+
+
+ ) + ); +}; diff --git a/src/cloud/msp/contexts/MSPProvider.tsx b/src/cloud/msp/contexts/MSPProvider.tsx new file mode 100644 index 0000000..7c37780 --- /dev/null +++ b/src/cloud/msp/contexts/MSPProvider.tsx @@ -0,0 +1,204 @@ +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import useFetchApi from "@utils/api"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { MSP } from "@/cloud/msp/interfaces/MSP"; +import { TenantListItem, TenantStatus } from "@/cloud/msp/interfaces/Tenant"; +import { useApplicationContext } from "@/contexts/ApplicationProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; + +type Props = { + children: React.ReactNode; +}; + +const MSPContext = React.createContext( + {} as { + mspInfo?: MSP; + isMspInfoLoading: boolean; + mspContact?: string; + + tenantListItems?: TenantListItem[]; + isTenantListItemsLoading: boolean; + + mspAccount?: TenantListItem; + currentAccount?: TenantListItem; + isActive: boolean; + + loginAs: (tenant?: TenantListItem) => void; + + isMSPInMSPContext: boolean; + isMSPInTenantContext: boolean; + isAccountWithoutMSP?: boolean; + isAccountWithMSPParent?: boolean; + + hasReseller?: boolean; + }, +); + +export default function MSPProvider({ children }: Readonly) { + const { + data: mspInfo, + isLoading: isMspInfoLoading, + error, + } = useFetchApi("/integrations/msp", true); + const { isOwner } = useLoggedInUser(); + + const { data: tenantListItems, isLoading: isTenantListItemsLoading } = + useFetchApi("/integrations/msp/switcher", true); + + const { globalApiParams, setGlobalApiParams } = useApplicationContext(); + const currentAccountId = globalApiParams?.account; + + const isActive = useMemo(() => { + try { + if (isMspInfoLoading || mspInfo === undefined || error) return false; + if (!Object.hasOwn(mspInfo, "activated_at")) return false; + return mspInfo.activated_at !== ""; + } catch (err) { + return false; + } + }, [isMspInfoLoading, mspInfo, error]); + + const refreshPage = useCallback(() => { + setTimeout(() => { + window.location.href = "/peers"; + }, 500); + }, []); + + useEffect(() => { + if (isTenantListItemsLoading || isMspInfoLoading) return; + if (currentAccountId) { + const tenant = tenantListItems?.find((t) => t.id === currentAccountId); + if (!tenant) { + setGlobalApiParams?.({}); + refreshPage(); + } + } + }, [ + tenantListItems, + currentAccountId, + isTenantListItemsLoading, + isMspInfoLoading, + setGlobalApiParams, + refreshPage, + ]); + + const mspAccount = useMemo(() => { + if (!mspInfo || isMspInfoLoading) return; + return { + id: "msp", + domain: mspInfo.parent_domain ?? mspInfo.domain, + name: mspInfo.parent_name ?? mspInfo.name, + isMSP: true, + } as TenantListItem; + }, [mspInfo, isMspInfoLoading]); + + const currentAccount = useMemo(() => { + return tenantListItems?.find((t) => t.id === currentAccountId); + }, [tenantListItems, currentAccountId]); + + const [isSwitching, setIsSwitching] = useState(false); + + const loginAs = useCallback( + (tenant?: TenantListItem) => { + setIsSwitching(true); + if (tenant?.isMSP || !tenant) { + setGlobalApiParams?.({}); + } else { + setGlobalApiParams?.({ account: tenant.id }); + } + refreshPage(); + }, + [setGlobalApiParams, refreshPage], + ); + + const mspContact = useMemo(() => { + if (!mspInfo) return; + if (mspInfo?.parent_owner_name) { + return `${mspInfo.parent_owner_name} (${ + mspInfo?.parent_owner_email || mspInfo?.parent_domain || mspInfo?.domain + })`; + } + return `${mspInfo?.parent_name || mspInfo?.name} (${ + mspInfo?.parent_domain || mspInfo?.domain + })`; + }, [mspInfo]); + + const isMSPInMSPContext = useMemo(() => { + if (isMspInfoLoading || mspInfo === undefined || error) return false; + if (!isActive) return false; + return Object.hasOwn(mspInfo, "name") && !currentAccount; + }, [isMspInfoLoading, mspInfo, error, isActive, currentAccount]); + + const isMSPInTenantContext = useMemo(() => { + if (isMspInfoLoading || mspInfo === undefined || error) return false; + return Object.hasOwn(mspInfo, "parent_name") && !!currentAccount; + }, [isMspInfoLoading, mspInfo, error, currentAccount]); + + const isAccountWithoutMSP = useMemo(() => { + if (isMspInfoLoading) return undefined; + if (!mspInfo || error) return true; + return ( + !Object.hasOwn(mspInfo, "parent_name") && + !Object.hasOwn(mspInfo, "name") && + !currentAccount + ); + }, [isMspInfoLoading, mspInfo, error, currentAccount]); + + const hasReseller = useMemo(() => { + if (isMspInfoLoading) return undefined; + return mspInfo?.reseller_status === "active"; + }, [isMspInfoLoading, mspInfo]); + + const isAccountWithMSPParent = useMemo(() => { + if (isMspInfoLoading) return undefined; + if (hasReseller) return true; + if (error || !mspInfo) return false; + const isExisting = mspInfo?.status === TenantStatus.Existing; + const isInvited = mspInfo?.status === TenantStatus.Invited; + if (isExisting || isInvited) return false; + return Object.hasOwn(mspInfo, "parent_name"); + }, [isMspInfoLoading, mspInfo, error]); + + const contextData = useMemo( + () => ({ + mspInfo, + isMspInfoLoading, + isActive, + tenantListItems, + isTenantListItemsLoading, + currentAccount, + mspAccount, + loginAs, + isMSPInTenantContext, + mspContact, + isMSPInMSPContext, + isAccountWithoutMSP, + isAccountWithMSPParent, + hasReseller, + }), + [ + mspInfo, + isMspInfoLoading, + isActive, + tenantListItems, + isTenantListItemsLoading, + currentAccount, + mspAccount, + loginAs, + mspContact, + isMSPInMSPContext, + isMSPInTenantContext, + isAccountWithoutMSP, + isAccountWithMSPParent, + hasReseller, + ], + ); + + return isSwitching ? ( + + ) : ( + {children} + ); +} + +export const useMSP = () => React.useContext(MSPContext); diff --git a/src/cloud/msp/contexts/TenantsProvider.tsx b/src/cloud/msp/contexts/TenantsProvider.tsx new file mode 100644 index 0000000..3845362 --- /dev/null +++ b/src/cloud/msp/contexts/TenantsProvider.tsx @@ -0,0 +1,365 @@ +import { notify } from "@components/Notification"; +import useFetchApi, { useApiCall } from "@utils/api"; +import * as React from "react"; +import { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import { + Tenant, + TenantDNSResponse, + TenantStatus, +} from "@/cloud/msp/interfaces/Tenant"; +import { MSPAccountExistsModal } from "@/cloud/msp/MSPAccountExistsModal"; +import { MSPDomainVerificationModal } from "@/cloud/msp/MSPDomainVerificationModal"; +import { MSPSubscriptionModal } from "@/cloud/msp/MSPSubscriptionModal"; +import { MSPTenantModal } from "@/cloud/msp/MSPTenantModal"; +import { MSPUnlinkModal } from "@/cloud/msp/MSPUnlinkModal"; +import { useDialog } from "@/contexts/DialogProvider"; +import { Currency, Plan } from "@/interfaces/Plan"; +import { User } from "@/interfaces/User"; + +type Props = { + children: React.ReactNode; +}; + +const TenantsContext = React.createContext( + {} as { + tenants?: Tenant[]; + openCreateTenantModal: () => void; + openEditTenantModal: (tenant: Tenant, initialTab?: string) => void; + openDomainVerificationModal: (tenant: Tenant, dnsChallenge: string) => void; + setCurrentTenant: React.Dispatch>; + verifyDomain: (tenant?: Tenant, openModal?: boolean) => Promise; + openUnlinkTenantModal: (tenant: Tenant) => void; + openAccountExistsModal: (tenant: Tenant) => void; + unlinkTenant: (tenant: Tenant, owner: User) => Promise; + deleteTenant: (tenant: Tenant) => Promise; + updateSubscription: ( + tenant: Tenant, + plan: Plan, + currency: Currency, + isOnFreeOrTrial: boolean, + ) => Promise; + }, +); + +export const TenantsProvider = ({ children }: Props) => { + const { data: tenants } = useFetchApi("/integrations/msp/tenants"); + const { mutate } = useSWRConfig(); + const [accountModal, setAccountModal] = useState(false); + const [initialTab, setInitialTab] = useState(); + const [currentTenant, setCurrentTenant] = useState(); + const [currentDNSChallenge, setCurrentDNSChallenge] = useState(""); + const [domainVerificationModal, setDomainVerificationModal] = useState(false); + const [subscriptionModal, setSubscriptionModal] = useState(false); + const [unlinkModal, setUnlinkModal] = useState(false); + const [accountExistsModal, setAccountExistsModal] = useState(false); + const { confirm } = useDialog(); + + const tenantRequest = useApiCall( + `/integrations/msp/tenants`, + true, + ); + + const mspTenantRequest = useApiCall( + `/integrations/msp/tenants`, + true, + ); + + const deleteTenant = async (tenant: Tenant) => { + const choice = await confirm({ + title: `Delete '${tenant.name}'?`, + description: + "Deleting this tenant will permanently remove all of its associated data, including its peers, users, groups and everything else. Please be aware that this action is irreversible and cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel", + type: "danger", + maxWidthClass: "max-w-[480px]", + }); + if (!choice) return; + + const promise = mspTenantRequest.del({}, `/${tenant.id}`).then(() => { + mutate("/integrations/msp/tenants"); + }); + + notify({ + title: `Deleting ${tenant.name}`, + description: `Successfully deleted ${tenant.name} (${tenant.domain})`, + loadingMessage: `Deleting ${tenant.name}...`, + promise, + }); + + return promise; + }; + + const unlinkTenant = async (tenant: Tenant, owner: User) => { + const promise = mspTenantRequest + .post( + { + owner: owner.id, + }, + `/${tenant.id}/unlink`, + ) + .then(() => { + setCurrentTenant(undefined); + setUnlinkModal(false); + mutate("/integrations/msp/tenants"); + }); + + notify({ + title: `Unlinking ${tenant.name}`, + description: `Successfully unlinked ${tenant.name} (${tenant.domain})`, + loadingMessage: `Unlinking ${tenant.name}...`, + promise, + }); + return promise; + }; + + const updateSubscription = async ( + tenant: Tenant, + plan: Plan, + currency: Currency, + isOnFreeOrTrial: boolean, + ) => { + let price = plan.prices.find((price) => price.currency === currency); + + // Initial MSP subscription for Free or Trial plans, otherwise update the subscription with regular billing endpoint + const promise = isOnFreeOrTrial + ? mspTenantRequest.post( + { + priceID: price?.price_id, + }, + `/${tenant.id}/subscription`, + ) + : mspTenantRequest.put( + { + priceID: price?.price_id, + }, + `/${tenant.id}/subscription`, + ); + + notify({ + title: `Subscription for ${tenant.name}`, + description: `Successfully subscribed to the ${plan.name} plan.`, + loadingMessage: `Subscribing to ${plan.name}...`, + promise, + }); + return promise; + }; + + const verifyDomain = async (tenant?: Tenant, openModal?: boolean) => { + let t = tenant || currentTenant; + if (!t) return; + const domain = t.domain; + + const dnsPromise = tenantRequest + .post({}, `/${t.id}/dns`) + .then(() => { + setCurrentTenant(t); + setSubscriptionModal(true); + }) + .catch((res) => { + setCurrentTenant(t); + setCurrentDNSChallenge(res.dns_challenge as string); + openModal && setDomainVerificationModal(true); + throw { code: 501 }; + }); + + notify({ + title: `Verification of ${domain}`, + description: "The domain ownership has been verified successfully.", + loadingTitle: `Verifying ownership of ${domain}`, + loadingMessage: "Please wait while we verify the domain ownership...", + promise: dnsPromise, + errorMessages: [ + { + code: 501, + message: "DNS record not found. Please try again...", + }, + ], + }); + + // Return the promise directly + return dnsPromise; + }; + + const sendAccountRequest = async (tenant?: Tenant) => { + let t = tenant || currentTenant; + if (!t) return; + + const request = tenantRequest + .post({}, `/${t.id}/invite`) + .then(() => { + mutate("/integrations/msp/tenants"); + mutate("/integrations/msp"); + mutate("/integrations/msp/switcher"); + }) + .finally(() => { + setAccountModal(false); + setAccountExistsModal(false); + setCurrentTenant(undefined); + }); + + notify({ + title: `Request Account Access`, + description: "Request has been sent successfully.", + loadingMessage: "Sending request...", + promise: request, + }); + + return request; + }; + + const cancelAccountRequest = (tenant?: Tenant) => { + let t = tenant || currentTenant; + if (!t) return; + return tenantRequest + .del({}, `/${t.id}`) + .then(() => { + mutate("/integrations/msp/tenants"); + mutate("/integrations/msp"); + mutate("/integrations/msp/switcher"); + }) + .finally(() => { + setAccountModal(false); + setAccountExistsModal(false); + setCurrentTenant(undefined); + }); + }; + + const contextData = useMemo(() => { + const openCreateTenantModal = () => { + setCurrentTenant(undefined); + setAccountModal(true); + }; + + const openEditTenantModal = (tenant: Tenant, initialTab?: string) => { + if (tenant.status === TenantStatus.Invited) return; + if (tenant.status === TenantStatus.Existing) return; + setCurrentTenant(tenant); + setInitialTab(initialTab); + setAccountModal(true); + }; + + const openAccountExistsModal = (tenant: Tenant) => { + setCurrentTenant(tenant); + setAccountExistsModal(true); + }; + + const openUnlinkTenantModal = (tenant: Tenant) => { + setCurrentTenant(tenant); + setUnlinkModal(true); + }; + + const openDomainVerificationModal = ( + tenant: Tenant, + dnsChallenge: string, + ) => { + setCurrentTenant(tenant); + setCurrentDNSChallenge(dnsChallenge); + setDomainVerificationModal(true); + }; + + return { + tenants, + openCreateTenantModal, + openEditTenantModal, + setCurrentTenant, + openDomainVerificationModal, + openUnlinkTenantModal, + openAccountExistsModal, + }; + }, [tenants]); + + return ( + + { + if (!state) { + setCurrentTenant(undefined); + setCurrentDNSChallenge(""); + setInitialTab(undefined); + } + setAccountModal(state); + }} + tenant={currentTenant} + initialTab={initialTab} + /> + {domainVerificationModal && currentDNSChallenge && currentTenant && ( + { + mutate("/integrations/msp/tenants"); + setDomainVerificationModal(false); + setCurrentDNSChallenge(""); + setInitialTab(undefined); + setSubscriptionModal(true); + }} + onCancel={() => { + mutate("/integrations/msp/tenants"); + setDomainVerificationModal(false); + setCurrentDNSChallenge(""); + + setAccountModal(false); + setInitialTab(undefined); + setCurrentTenant(undefined); + }} + /> + )} + + {subscriptionModal && currentTenant && ( + { + if (!o) { + setAccountModal(false); + setCurrentTenant(undefined); + setInitialTab(undefined); + } + setSubscriptionModal(o); + }} + tenant={currentTenant} + /> + )} + + {unlinkModal && currentTenant && ( + + )} + + {accountExistsModal && currentTenant && ( + + )} + + {children} + + ); +}; + +export const useTenants = () => { + const context = React.useContext(TenantsContext); + if (context === undefined) { + throw new Error("useTenants must be used within a TenantsProvider"); + } + return context; +}; diff --git a/src/cloud/msp/hooks/useTenantPlan.ts b/src/cloud/msp/hooks/useTenantPlan.ts new file mode 100644 index 0000000..61c7f6f --- /dev/null +++ b/src/cloud/msp/hooks/useTenantPlan.ts @@ -0,0 +1,145 @@ +import useFetchApi from "@utils/api"; +import { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import { useTenants } from "@/cloud/msp/contexts/TenantsProvider"; +import { useTenantSubscription } from "@/cloud/msp/hooks/useTenantSubscription"; +import { Tenant } from "@/cloud/msp/interfaces/Tenant"; +import { useBilling } from "@/contexts/BillingProvider"; +import { AccountUsageStats } from "@/interfaces/AccountUsageStats"; +import { Plan } from "@/interfaces/Plan"; +import { PlanTier } from "@/interfaces/Subscription"; + +type Props = { + tenant: Tenant; + withUsage?: boolean; +}; + +export const useTenantPlan = ({ tenant, withUsage = true }: Props) => { + const { mutate } = useSWRConfig(); + const { updateSubscription } = useTenants(); + const { + currency, + plans, + isLoading: isBillingLoading, + getCurrentPlanByPlanTier, + calculateEstimatedPrice, + } = useBilling(); + + // Usage stats + const { data: stats, isLoading: isStatsLoading } = + useFetchApi( + `/integrations/billing/usage?account=${tenant.id}`, + true, + false, + withUsage, + ); + + // Subscription status + const { + currentPlanTier, + subscription, + trialDaysRemaining, + isTrialExpired, + isSubscriptionLoading, + } = useTenantSubscription({ tenantId: tenant.id }); + + const teamAndBusinessPlans = plans?.filter( + (plan) => + plan.name.toLowerCase().includes("team") || + plan.name.toLowerCase().includes("business"), + ); + + const [isSubscribing, setIsSubscribing] = useState({ + team: false, + business: false, + }); + + const subscribe = async (plan: Plan) => { + if (!tenant) return; + if (!subscription) return; + + let name = plan?.name?.toLowerCase() || ""; + setIsSubscribing({ + team: name === PlanTier.TEAM, + business: name === PlanTier.BUSINESS, + }); + + const isOnFreeOrTrial = + subscription.plan_tier === PlanTier.FREE || + subscription.plan_tier === PlanTier.TRIAL; + + return updateSubscription(tenant, plan, currency, isOnFreeOrTrial) + .then(() => { + mutate(`/integrations/billing/subscription?account=${tenant.id}`); + }) + .finally(() => { + setIsSubscribing({ + team: false, + business: false, + }); + }); + }; + + const currentPlan = useMemo(() => { + if (!plans || !currentPlanTier) return; + return getCurrentPlanByPlanTier(currentPlanTier); + }, [plans, currentPlanTier, getCurrentPlanByPlanTier]); + + const currentPlanPrice = useMemo(() => { + return currentPlan?.prices.find( + (price) => price.price_id === subscription?.price_id, + ); + }, [currentPlan, subscription]); + + const estimatedPrice = useMemo(() => { + if (!currentPlan || !stats || !currency) return 0; + return calculateEstimatedPrice(currentPlan, currency, stats); + }, [currentPlan, stats, currency, calculateEstimatedPrice]); + + const isFreePlan = currentPlan + ? currentPlan.name.toLowerCase().includes(PlanTier.FREE) + : true; + + const isTrial = useMemo(() => { + if (isSubscriptionLoading && !subscription) return undefined; + if (subscription?.plan_tier === PlanTier.BUSINESS) return false; + if (subscription?.plan_tier === PlanTier.ENTERPRISE) return false; + if (subscription?.remaining_trial === undefined) return false; + return subscription.remaining_trial > 0; + }, [subscription, isSubscriptionLoading]); + + const maxPeersOfPlan = useMemo(() => { + const freeUsers = 0; + return ( + 100 + + Math.max( + currentPlan && !currentPlan.name.toLowerCase().includes(PlanTier.FREE) + ? ((stats?.active_users || 1) - freeUsers) * 10 + : 0, + 0, + ) + ); + }, [currentPlan, stats?.active_users]); + + const isLoading = isBillingLoading || isSubscriptionLoading || isStatsLoading; + + return { + teamAndBusinessPlans, + isSubscribing, + subscribe, + currentPlan, + subscription, + isLoading, + plans: teamAndBusinessPlans, + currency, + stats, + trialDaysRemaining, + currentPlanTier, + isTrialExpired, + estimatedPrice, + currentPlanPrice, + isFreePlan, + isTrial, + maxPeersOfPlan, + }; +}; diff --git a/src/cloud/msp/hooks/useTenantSubscription.ts b/src/cloud/msp/hooks/useTenantSubscription.ts new file mode 100644 index 0000000..5bb64e0 --- /dev/null +++ b/src/cloud/msp/hooks/useTenantSubscription.ts @@ -0,0 +1,51 @@ +import useFetchApi from "@utils/api"; +import { useMemo } from "react"; +import { PlanTier, Subscription } from "@/interfaces/Subscription"; + +type Props = { + tenantId: string; + allowFetch?: boolean; +}; +export const useTenantSubscription = ({ + tenantId, + allowFetch = true, +}: Props) => { + const { data: subscription, isLoading: isSubscriptionLoading } = + useFetchApi( + `/integrations/billing/subscription?account=${tenantId}`, + true, + false, + allowFetch, + { + ignoreGlobalParams: true, + }, + ); + const currentPlanTier = useMemo(() => { + if (!subscription) return; + if (subscription.plan_tier == PlanTier.UNKNOWN) return PlanTier.FREE; + return subscription.plan_tier; + }, [subscription]); + + const trialDaysRemaining = useMemo(() => { + if (subscription?.remaining_trial === undefined) return undefined; + return Math.ceil(subscription.remaining_trial / 86400); + }, [subscription]); + + const isTrialExpired = useMemo(() => { + if ( + currentPlanTier === PlanTier.TRIAL || + currentPlanTier === PlanTier.FREE + ) { + return trialDaysRemaining === 0; + } + return false; + }, [currentPlanTier, trialDaysRemaining]); + + return { + subscription, + currentPlanTier, + isSubscriptionLoading, + trialDaysRemaining, + isTrialExpired, + }; +}; diff --git a/src/cloud/msp/interfaces/Invoice.ts b/src/cloud/msp/interfaces/Invoice.ts new file mode 100644 index 0000000..34ee8e6 --- /dev/null +++ b/src/cloud/msp/interfaces/Invoice.ts @@ -0,0 +1,10 @@ +export interface Invoice { + id: string; + period_start: string; + period_end: string; + type: "account" | "tenants"; +} + +export interface InvoicePDF { + url: string; +} diff --git a/src/cloud/msp/interfaces/MSP.ts b/src/cloud/msp/interfaces/MSP.ts new file mode 100644 index 0000000..fd557a7 --- /dev/null +++ b/src/cloud/msp/interfaces/MSP.ts @@ -0,0 +1,19 @@ +import { TenantStatus } from "@/cloud/msp/interfaces/Tenant"; + +export interface MSP { + id: string; + activated_at: string; + domain?: string; + name: string; + + parent?: string; + parent_name: string; + parent_domain: string; + + parent_owner_name?: string; + parent_owner_email?: string; + + status: TenantStatus; + + reseller_status?: "active" | "invited"; +} diff --git a/src/cloud/msp/interfaces/Tenant.ts b/src/cloud/msp/interfaces/Tenant.ts new file mode 100644 index 0000000..9a1cb80 --- /dev/null +++ b/src/cloud/msp/interfaces/Tenant.ts @@ -0,0 +1,51 @@ +import { Role } from "@/interfaces/User"; + +export interface Tenant { + id: string; + name: string; + domain: string; + groups: TenantGroup[]; + activated_at?: string; + disabled_at: string; + created_at: string; + updated_at: string; + status: TenantStatus; +} + +export interface TenantListItem { + id: string; + account_id: string; + activated_at: string; + created_at: string; + disabled_at: string; + name: string; + domain: string; + updated_at: string; + status: TenantStatus; + isMSP?: boolean; // Frontend only +} + +export interface TenantDNSResponse { + id: string; + name: string; + dns_challenge: string; + domain: string; + status: TenantStatus; + groups: TenantGroup[]; + disabled_at: string; + created_at: string; + updated_at: string; +} + +export interface TenantGroup { + id?: string; + role: Role; + name?: string; +} + +export enum TenantStatus { + Existing = "existing", + Invited = "invited", + Pending = "pending", + Active = "active", +} diff --git a/src/cloud/msp/table/TenantActionCell.tsx b/src/cloud/msp/table/TenantActionCell.tsx new file mode 100644 index 0000000..a39a5aa --- /dev/null +++ b/src/cloud/msp/table/TenantActionCell.tsx @@ -0,0 +1,126 @@ +import Badge from "@components/Badge"; +import Button from "@components/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; +import FullTooltip from "@components/FullTooltip"; +import { + HelpCircle, + MoreVertical, + ShieldCheckIcon, + ShieldUserIcon, + SquarePenIcon, + Trash2, + UnlinkIcon, +} from "lucide-react"; +import * as React from "react"; +import { useTenants } from "@/cloud/msp/contexts/TenantsProvider"; +import { Tenant, TenantStatus } from "@/cloud/msp/interfaces/Tenant"; + +type Props = { + tenant: Tenant; +}; +export const TenantActionCell = ({ tenant }: Props) => { + const { + openEditTenantModal, + verifyDomain, + openUnlinkTenantModal, + deleteTenant, + openAccountExistsModal, + } = useTenants(); + const isPending = tenant.status === TenantStatus.Pending; + const isActive = tenant.status === TenantStatus.Active; + const isExisting = tenant.status === TenantStatus.Existing; + const isInvited = tenant.status === TenantStatus.Invited; + const canEdit = isPending || isActive; + + return ( +
+ {isPending && ( + + )} + + {isExisting && ( + + )} + + {isInvited && ( + + The account owner must log in to the dashboard to accept or + decline your request. +
+ } + > + + Pending access request + + + + )} + + + + { + e.stopPropagation(); + e.preventDefault(); + }} + > + + + + openUnlinkTenantModal(tenant)}> +
+ + Unlink +
+
+ + deleteTenant(tenant)} + > +
+ + Delete +
+
+
+
+ + ); +}; diff --git a/src/cloud/msp/table/TenantGroupsCell.tsx b/src/cloud/msp/table/TenantGroupsCell.tsx new file mode 100644 index 0000000..f1dd2e8 --- /dev/null +++ b/src/cloud/msp/table/TenantGroupsCell.tsx @@ -0,0 +1,195 @@ +import Badge from "@components/Badge"; +import Button from "@components/Button"; +import { ScrollArea } from "@components/ScrollArea"; +import GroupBadge from "@components/ui/GroupBadge"; +import * as HoverCard from "@radix-ui/react-hover-card"; +import { IconCirclePlus } from "@tabler/icons-react"; +import { cn } from "@utils/helpers"; +import { orderBy } from "lodash"; +import { ArrowRightIcon } from "lucide-react"; +import * as React from "react"; +import { useMemo } from "react"; +import { useTenants } from "@/cloud/msp/contexts/TenantsProvider"; +import { + Tenant, + TenantGroup, + TenantStatus, +} from "@/cloud/msp/interfaces/Tenant"; +import { useGroups } from "@/contexts/GroupsProvider"; +import { useUsers } from "@/contexts/UsersProvider"; +import { Group } from "@/interfaces/Group"; +import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack"; +import { UserRoles } from "@/modules/users/UserRoleSelector"; + +type Props = { + tenant: Tenant; +}; +export const TenantGroupsCell = ({ tenant }: Props) => { + const { groups } = useGroups(); + const { openEditTenantModal } = useTenants(); + const isPending = tenant.status === TenantStatus.Pending; + const isActive = tenant.status === TenantStatus.Active; + const canEdit = isPending || isActive; + + const tenantGroups = useMemo(() => { + return tenant.groups?.map( + (tenantGroup) => + ({ + ...tenantGroup, + name: groups?.find((g) => g.id === tenantGroup.id)?.name, + }) as TenantGroup, + ); + }, [tenant.groups, groups]); + + if (tenantGroups?.length === 0) + return ( + + ); + + return ( + openEditTenantModal(tenant, "permissions")} + /> + ); +}; + +type MultipleGroupsWithUserProps = { + tenantGroups: TenantGroup[]; + onClick?: () => void; +}; + +const MultipleGroupsWithUser = ({ + tenantGroups, + onClick, +}: MultipleGroupsWithUserProps) => { + const firstGroup = tenantGroups.length > 0 ? tenantGroups[0] : undefined; + const otherGroups = tenantGroups.length > 0 ? tenantGroups.slice(1) : []; + const [isClicked, setIsClicked] = React.useState(false); + const [open, setOpen] = React.useState(false); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsClicked(true); + setOpen(false); + onClick?.(); + setTimeout(() => setIsClicked(false), 300); + }; + + return ( + !isClicked && setOpen(newOpen)} + > + + + + + + +
+ {tenantGroups.map((group) => ( + + ))} +
+
+
+
+
+ ); +}; + +const GroupWithUserItem = ({ group }: { group: TenantGroup }) => { + const { users } = useUsers(); + const usersOfGroup = + users?.filter((user) => user.auto_groups.includes(group.id as string)) || + []; + const selectedRole = UserRoles.find((r) => r.value === group.role); + + return ( +
+
+ + + +
+ + {selectedRole && ( +
+
+ + {selectedRole?.name} +
+
+ )} +
+ ); +}; + +const GroupSuffix = () => { + const oneOrTwo = Math.random() > 0.5 ? 1 : 2; + const selectedRole = UserRoles[oneOrTwo]; + return ( +
+
+ + {selectedRole?.name} +
+
+ ); +}; diff --git a/src/cloud/msp/table/TenantNameCell.tsx b/src/cloud/msp/table/TenantNameCell.tsx new file mode 100644 index 0000000..a7c997e --- /dev/null +++ b/src/cloud/msp/table/TenantNameCell.tsx @@ -0,0 +1,77 @@ +import { cn, generateColorFromString } from "@utils/helpers"; +import { CircleAlertIcon, Clock } from "lucide-react"; +import * as React from "react"; +import { useTenantPlan } from "@/cloud/msp/hooks/useTenantPlan"; +import { Tenant, TenantStatus } from "@/cloud/msp/interfaces/Tenant"; + +type Props = { + tenant: Tenant; +}; + +export const TenantNameCell = ({ tenant }: Props) => { + const { isTrialExpired } = useTenantPlan({ + tenant, + }); + + const isActive = tenant.status === TenantStatus.Active; + + return ( +
+
+ {tenant.name.charAt(0)} + +
+
+ + {tenant.name} + + {tenant.domain} +
+
+ ); +}; + +type AvatarBadgeProps = { + isActive: boolean; + isTrialExpired?: boolean; +}; + +const AvatarBadge = ({ + isActive, + isTrialExpired = false, +}: AvatarBadgeProps) => { + if (!isActive) { + return ( +
+ +
+ ); + } + if (isTrialExpired) + return ( +
+ +
+ ); +}; diff --git a/src/cloud/msp/table/TenantPeersCell.tsx b/src/cloud/msp/table/TenantPeersCell.tsx new file mode 100644 index 0000000..87790f2 --- /dev/null +++ b/src/cloud/msp/table/TenantPeersCell.tsx @@ -0,0 +1,36 @@ +import Badge from "@components/Badge"; +import { cn } from "@utils/helpers"; +import { MonitorSmartphoneIcon } from "lucide-react"; +import * as React from "react"; +import Skeleton from "react-loading-skeleton"; +import { useTenantPlan } from "@/cloud/msp/hooks/useTenantPlan"; +import { Tenant, TenantStatus } from "@/cloud/msp/interfaces/Tenant"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; + +type Props = { + tenant: Tenant; +}; + +export const TenantPeersCell = ({ tenant }: Props) => { + const { stats, isLoading } = useTenantPlan({ + tenant, + }); + + if (isLoading) return ; + + const isActive = tenant.status === TenantStatus.Active; + if (!isActive) return ; + + return ( +
+ + +
+ + {stats?.active_peers || 0} + +
+
+
+ ); +}; diff --git a/src/cloud/msp/table/TenantPlanCell.tsx b/src/cloud/msp/table/TenantPlanCell.tsx new file mode 100644 index 0000000..c7a445c --- /dev/null +++ b/src/cloud/msp/table/TenantPlanCell.tsx @@ -0,0 +1,99 @@ +import Badge from "@components/Badge"; +import Button from "@components/Button"; +import { cn } from "@utils/helpers"; +import { CircleAlertIcon, CreditCardIcon } from "lucide-react"; +import * as React from "react"; +import Skeleton from "react-loading-skeleton"; +import { useTenants } from "@/cloud/msp/contexts/TenantsProvider"; +import { useTenantPlan } from "@/cloud/msp/hooks/useTenantPlan"; +import { Tenant, TenantStatus } from "@/cloud/msp/interfaces/Tenant"; +import { PlanTier } from "@/interfaces/Subscription"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; + +type Props = { + tenant: Tenant; +}; + +export const TenantPlanCell = ({ tenant }: Props) => { + const { openEditTenantModal } = useTenants(); + + const { currentPlanTier, isLoading, trialDaysRemaining, isTrialExpired } = + useTenantPlan({ + tenant, + }); + + const isInvited = tenant.status === TenantStatus.Invited; + const isExisting = tenant.status === TenantStatus.Existing; + + if (isInvited || isExisting) return ; + + if (isLoading || !currentPlanTier) + return ; + + if (isTrialExpired) + return ( +
+ + + Trial Expired + + +
+ ); + + const isActive = tenant.status === TenantStatus.Active; + if (!isActive) return ; + + return ( +
+ + {currentPlanTier === PlanTier.TRIAL && ( + + )} +
+ ); +}; + +type RemainingTrialDaysProps = { + days?: number; +}; + +const RemainingTrialDays = ({ days }: RemainingTrialDaysProps) => { + if (days === undefined) return null; + if (days === 1) return ` (${days} day left)`; + if (days === 0) return ` (Trial has expired)`; + + return ` (${days} days left)`; +}; + +const CurrentPlan = ({ plan }: { plan: PlanTier }) => { + return ( + <> + + {plan == PlanTier.BUSINESS && "Business"} + {plan == PlanTier.TEAM && "Team"} + {plan == PlanTier.FREE && "Free"} + {plan == PlanTier.TRIAL && "Free Trial"} + + ); +}; diff --git a/src/cloud/msp/table/TenantPlanCostCell.tsx b/src/cloud/msp/table/TenantPlanCostCell.tsx new file mode 100644 index 0000000..1ab6f11 --- /dev/null +++ b/src/cloud/msp/table/TenantPlanCostCell.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { useMemo } from "react"; +import Skeleton from "react-loading-skeleton"; +import { useTenantPlan } from "@/cloud/msp/hooks/useTenantPlan"; +import { Tenant, TenantStatus } from "@/cloud/msp/interfaces/Tenant"; +import { useBilling } from "@/contexts/BillingProvider"; +import { Currency } from "@/interfaces/Plan"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; + +type Props = { + tenant: Tenant; +}; + +export const TenantPlanCostCell = ({ tenant }: Props) => { + const { calculateEstimatedPrice, currency } = useBilling(); + const { isLoading, currentPlan, stats } = useTenantPlan({ + tenant, + }); + + const isActive = tenant.status === TenantStatus.Active; + const isInvited = tenant.status === TenantStatus.Invited; + const isExisting = tenant.status === TenantStatus.Existing; + + const estimatedPrice = useMemo(() => { + if (!currentPlan || !stats || !currency || !isActive) return 0; + return calculateEstimatedPrice(currentPlan, currency, stats); + }, [currentPlan, stats, currency, isActive, calculateEstimatedPrice]); + + if (isLoading) return ; + if (isInvited || isExisting) return ; + + return ( +
+ + {currency == Currency.USD && "$ "} + {estimatedPrice.toFixed(2)} + {currency == Currency.EUR && " €"} + +
+ ); +}; diff --git a/src/cloud/msp/table/TenantUsersCell.tsx b/src/cloud/msp/table/TenantUsersCell.tsx new file mode 100644 index 0000000..183eaf0 --- /dev/null +++ b/src/cloud/msp/table/TenantUsersCell.tsx @@ -0,0 +1,38 @@ +import Badge from "@components/Badge"; +import { cn } from "@utils/helpers"; +import { Users2Icon } from "lucide-react"; +import * as React from "react"; +import Skeleton from "react-loading-skeleton"; +import { useTenantPlan } from "@/cloud/msp/hooks/useTenantPlan"; +import { Tenant, TenantStatus } from "@/cloud/msp/interfaces/Tenant"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; + +type Props = { + tenant: Tenant; +}; + +export const TenantUsersCell = ({ tenant }: Props) => { + const { stats, isLoading } = useTenantPlan({ + tenant, + }); + + if (isLoading) return ; + + const isActive = tenant.status === TenantStatus.Active; + if (!isActive) return ; + + return ( +
+
+ + +
+ + {stats?.active_users || 0} + +
+
+
+
+ ); +}; diff --git a/src/cloud/notifications/NotificationChannelListItem.tsx b/src/cloud/notifications/NotificationChannelListItem.tsx new file mode 100644 index 0000000..7470304 --- /dev/null +++ b/src/cloud/notifications/NotificationChannelListItem.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useRef, useState } from "react"; +import { ChevronRight, GlobeIcon, Loader2, MailIcon } from "lucide-react"; +import { cn } from "@utils/helpers"; +import { + ALL_NOTIFICATION_EVENT_TYPES, + NotificationChannel, + NotificationChannelType, + NotificationEmailChannel, +} from "@/interfaces/NotificationChannel"; +import { useNotifications } from "@/cloud/notifications/NotificationProvider"; +import SlackIcon from "@/assets/icons/SlackIcon"; + +type NotificationChannelListItemProps = { + type: NotificationChannelType; + channel?: NotificationChannel; + onClick?: () => void; + disabled?: boolean; +}; + +const channelMeta: Record< + NotificationChannelType, + { name: string; icon: React.ReactNode } +> = { + [NotificationChannelType.Email]: { name: "Email", icon: }, + [NotificationChannelType.Webhook]: { name: "Webhook", icon: }, + [NotificationChannelType.Slack]: { name: "Slack", icon: }, +}; + +const getChannelDescription = ( + type: NotificationChannelType, + channel?: NotificationChannel, +) => { + if (!channel) return "Disabled"; + if (!channel.enabled) return "Disabled"; + + const totalTypes = ALL_NOTIFICATION_EVENT_TYPES.length; + const activeTypes = channel.event_types.length; + const notificationLabel = + activeTypes === totalTypes + ? "All Notifications" + : `${activeTypes} of ${totalTypes} Notifications`; + + if (type === NotificationChannelType.Email) { + const emails = (channel.target as NotificationEmailChannel)?.emails ?? []; + return `Enabled · ${notificationLabel} · ${emails.length} Recipient${ + emails.length !== 1 ? "s" : "" + }`; + } + return `Enabled · ${notificationLabel}`; +}; + +export const NotificationChannelListItem = ({ + type, + channel, + onClick, + disabled, +}: NotificationChannelListItemProps) => { + const { createDefaultChannel } = useNotifications(); + const [isCreating, setIsCreating] = useState(false); + const [showLoading, setShowLoading] = useState(false); + const timerRef = useRef>(undefined); + const meta = channelMeta[type]; + const active = channel?.enabled ?? false; + const description = getChannelDescription(type, channel); + + useEffect(() => { + return () => clearTimeout(timerRef.current); + }, []); + + const handleClick = async () => { + if (!channel) { + setIsCreating(true); + timerRef.current = setTimeout(() => setShowLoading(true), 200); + try { + await createDefaultChannel(type); + } finally { + clearTimeout(timerRef.current); + setIsCreating(false); + setShowLoading(false); + } + } + onClick?.(); + }; + + return ( + + ); +}; diff --git a/src/cloud/notifications/NotificationEventTypes.tsx b/src/cloud/notifications/NotificationEventTypes.tsx new file mode 100644 index 0000000..a8e4359 --- /dev/null +++ b/src/cloud/notifications/NotificationEventTypes.tsx @@ -0,0 +1,146 @@ +import * as React from "react"; +import { Label } from "@components/Label"; +import PeerIcon from "@/assets/icons/PeerIcon"; +import Card from "@components/Card"; +import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import TeamIcon from "@/assets/icons/TeamIcon"; +import IntegrationIcon from "@/assets/icons/IntegrationIcon"; +import { NotificationEventType } from "@/interfaces/NotificationChannel"; + +type EventTypeMeta = { + key: NotificationEventType; + label: string; + helpText: string; + group: "peer" | "user" | "integration"; +}; + +const EVENT_TYPE_METADATA: EventTypeMeta[] = [ + { + key: NotificationEventType.PeerPendingApproval, + label: "Pending Approval", + helpText: "Notify when a peer is waiting for approval to join the network", + group: "peer", + }, + { + key: NotificationEventType.PeerAdd, + label: "Peer Added", + helpText: "Notify when a new peer is added to the network", + group: "peer", + }, + { + key: NotificationEventType.RoutingPeerDisconnect, + label: "Routing Peer Disconnected", + helpText: "Notify when a routing peer loses its connection", + group: "peer", + }, + { + key: NotificationEventType.RoutingPeerDelete, + label: "Routing Peer Deleted", + helpText: "Notify when a routing peer is deleted from the network", + group: "peer", + }, + { + key: NotificationEventType.UserPendingApproval, + label: "User Pending Approval", + helpText: "Notify when a user is waiting for approval to join the network", + group: "user", + }, + { + key: NotificationEventType.UserJoin, + label: "User Joined", + helpText: "Notify when a new user joins the account", + group: "user", + }, + { + key: NotificationEventType.ServiceUserCreate, + label: "Service User Created", + helpText: "Notify when a new service user is created", + group: "user", + }, + { + key: NotificationEventType.IdpSyncTokenExpire, + label: "IdP Sync Token Expired", + helpText: "Notify when the IdP sync token has expired and needs renewal", + group: "integration", + }, + { + key: NotificationEventType.EdrSyncTokenExpire, + label: "EDR Sync Token Expired", + helpText: "Notify when the EDR sync token has expired and needs renewal", + group: "integration", + }, +]; + +const GROUP_CONFIG = { + peer: { + label: "Peer Notifications", + icon: , + }, + user: { + label: "User Notifications", + icon: , + }, + integration: { + label: "Integration Notifications", + icon: , + }, +} as const; + +const GROUPS: Array<"peer" | "user" | "integration"> = [ + "peer", + "user", + "integration", +]; + +type Props = { + event_types: NotificationEventType[]; + onToggle: (type: NotificationEventType) => void; + disabled?: boolean; +}; + +export const NotificationEventTypes = ({ event_types, onToggle, disabled }: Props) => { + return ( + <> + {GROUPS.map((group) => { + const config = GROUP_CONFIG[group]; + const types = EVENT_TYPE_METADATA.filter((t) => t.group === group); + return ( +
+ + + {types.map((type, index) => ( + + {index > 0 && } + onToggle(type.key)} + disabled={disabled} + data-testid={`notification-event-${type.key}`} + label={type.label} + helpText={type.helpText} + variant={"blank"} + className={ + "px-5 py-4 hover:bg-nb-gray-930 transition-colors duration-150" + } + textWrapperClassName={""} + /> + + ))} + +
+ ); + })} + + ); +}; + +const Separator = () => ( + +); diff --git a/src/cloud/notifications/NotificationProvider.tsx b/src/cloud/notifications/NotificationProvider.tsx new file mode 100644 index 0000000..fa801f2 --- /dev/null +++ b/src/cloud/notifications/NotificationProvider.tsx @@ -0,0 +1,146 @@ +import React from "react"; +import useFetchApi, { useApiCall } from "@utils/api"; +import { + ALL_NOTIFICATION_EVENT_TYPES, + NotificationChannel, + NotificationChannelType, + NotificationEventType, + NotificationEventTypeMap, +} from "@/interfaces/NotificationChannel"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; + +const DEFAULT_EMAIL_CHANNEL: Omit = { + type: NotificationChannelType.Email, + enabled: false, + event_types: [...ALL_NOTIFICATION_EVENT_TYPES], +}; + +const DEFAULT_WEBHOOK_CHANNEL: Omit = { + type: NotificationChannelType.Webhook, + enabled: false, + event_types: [...ALL_NOTIFICATION_EVENT_TYPES], +}; + +const DEFAULT_SLACK_CHANNEL: Omit = { + type: NotificationChannelType.Slack, + enabled: false, + event_types: [...ALL_NOTIFICATION_EVENT_TYPES], +}; + +type NotificationContextType = { + types: NotificationEventTypeMap | undefined; + isTypesLoading: boolean; + channels: NotificationChannel[] | undefined; + isChannelsLoading: boolean; + isLoading: boolean; + getFirstChannelByType: ( + type: NotificationChannelType, + ) => NotificationChannel | undefined; + createChannel: (channel: NotificationChannel) => Promise; + createDefaultChannel: (type: NotificationChannelType) => Promise; + updateChannel: (channel: NotificationChannel) => Promise; + toggleType: (channel: NotificationChannel, eventType: NotificationEventType) => Promise; +}; + +const NotificationContext = React.createContext( + {} as NotificationContextType, +); + +type Props = { + children: React.ReactNode; +}; + +export default function NotificationProvider({ children }: Props) { + const { loggedInUser } = useLoggedInUser(); + + const { data: types, isLoading: isTypesLoading } = + useFetchApi("/integrations/notifications/types"); + + const { + data: channels, + isLoading: isChannelsLoading, + mutate, + } = useFetchApi( + "/integrations/notifications/channels", + ); + + const channelRequest = useApiCall( + "/integrations/notifications/channels", + ); + + const isLoading = isTypesLoading || isChannelsLoading; + + const getFirstChannelByType = (type: NotificationChannelType) => { + return channels?.find((c) => c.type === type); + }; + + const createChannel = async (channel: NotificationChannel) => { + const result = await channelRequest.post(channel); + await mutate(); + return result; + }; + + const createDefaultChannel = async (type: NotificationChannelType) => { + let channel: NotificationChannel; + switch (type) { + case NotificationChannelType.Email: + channel = { ...DEFAULT_EMAIL_CHANNEL }; + if (loggedInUser?.email) { + channel.target = { emails: [loggedInUser.email] }; + } + break; + case NotificationChannelType.Slack: + channel = { ...DEFAULT_SLACK_CHANNEL }; + break; + case NotificationChannelType.Webhook: + channel = { ...DEFAULT_WEBHOOK_CHANNEL }; + break; + } + return createChannel(channel); + }; + + const updateChannel = async (channel: NotificationChannel) => { + const { id, ...payload } = channel; + const result = await channelRequest.put(payload, `/${id}`); + await mutate(); + return result; + }; + + const toggleType = async ( + channel: NotificationChannel, + eventType: NotificationEventType, + ) => { + const hasType = channel.event_types.includes(eventType); + const updatedChannel = { + ...channel, + event_types: hasType + ? channel.event_types.filter((t) => t !== eventType) + : [...channel.event_types, eventType], + }; + await updateChannel(updatedChannel); + }; + + return ( + + {children} + + ); +} + +export const useNotifications = () => { + return React.useContext(NotificationContext); +}; + diff --git a/src/cloud/notifications/NotificationTab.tsx b/src/cloud/notifications/NotificationTab.tsx new file mode 100644 index 0000000..fdc1401 --- /dev/null +++ b/src/cloud/notifications/NotificationTab.tsx @@ -0,0 +1,191 @@ +import Breadcrumbs from "@components/Breadcrumbs"; +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 { NotificationWebhookChannel } from "@/cloud/notifications/channels/NotificationWebhookChannel"; +import useUrlTab from "@/hooks/useUrlTab"; +import Paragraph from "@components/Paragraph"; +import NotificationProvider, { + useNotifications, +} from "@/cloud/notifications/NotificationProvider"; +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, +}: { + onSelectChannel: (value: string) => void; +}) => { + const { getFirstChannelByType } = useNotifications(); + const { permission } = usePermissions(); + const canUpdate = permission?.settings?.update ?? false; + + const emailChannel = getFirstChannelByType(NotificationChannelType.Email); + const webhookChannel = getFirstChannelByType(NotificationChannelType.Webhook); + const slackChannel = getFirstChannelByType(NotificationChannelType.Slack); + + return ( +
+ + } + /> + } + active + /> + +
+
+

Notifications

+ + Choose how to be notified when important events occur in your + account. + + + Learn more about{" "} + + Notification Channels + + + in our documentation. + +
+
+ + onSelectChannel(NotificationChannelType.Email)} + disabled={!canUpdate && !emailChannel} + /> + onSelectChannel(NotificationChannelType.Webhook)} + disabled={!canUpdate && !webhookChannel} + /> + onSelectChannel(NotificationChannelType.Slack)} + disabled={!canUpdate && !slackChannel} + /> + +
+ ); +}; + +export const NotificationTab = () => { + const { permission } = usePermissions(); + const { isLicensed } = useIsLicensed(); + const [channel, setChannel] = useUrlTab( + [ + NotificationChannelType.Email, + NotificationChannelType.Webhook, + NotificationChannelType.Slack, + ], + "", + "channel", + ); + + const canView = permission?.settings?.read && isLicensed; + if (!canView) return; + + return ( + + + + + + ); +}; + +const NotificationTabContent = ({ + channel, + onSelectChannel, +}: { + channel: string; + onSelectChannel: (value: string) => void; +}) => { + const { isLoading } = useNotifications(); + + if (isLoading) return ; + + if (channel === NotificationChannelType.Email) + return ; + if (channel === NotificationChannelType.Webhook) + return ; + if (channel === NotificationChannelType.Slack) + return ; + return ; +}; + +const NotificationEmailChannelPage = () => { + const { getFirstChannelByType } = useNotifications(); + const channel = getFirstChannelByType(NotificationChannelType.Email); + if (!channel) return null; + return ; +}; + +const NotificationWebhookChannelPage = () => { + const { getFirstChannelByType } = useNotifications(); + const channel = getFirstChannelByType(NotificationChannelType.Webhook); + if (!channel) return null; + return ; +}; + +const NotificationSlackChannelPage = () => { + const { getFirstChannelByType } = useNotifications(); + const channel = getFirstChannelByType(NotificationChannelType.Slack); + if (!channel) return null; + return ; +}; + +export const NotificationsTabTrigger = () => { + const { permission } = usePermissions(); + const { isLicensed } = useIsLicensed(); + + const canView = permission?.settings?.read && isLicensed; + if (!canView) return; + + return ( + + + Notifications + + + ); +}; diff --git a/src/cloud/notifications/channels/NotificationEmailChannel.tsx b/src/cloud/notifications/channels/NotificationEmailChannel.tsx new file mode 100644 index 0000000..4c9db4e --- /dev/null +++ b/src/cloud/notifications/channels/NotificationEmailChannel.tsx @@ -0,0 +1,200 @@ +import * as React from "react"; +import { useRef, useState } from "react"; +import Breadcrumbs from "@components/Breadcrumbs"; +import { cn } from "@utils/helpers"; +import { Label } from "@components/Label"; +import HelpText from "@components/HelpText"; +import { + MailIcon, + MessageSquareDot, + PlusCircle, + Power, + XIcon, +} from "lucide-react"; +import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import { Input } from "@components/Input"; +import Button from "@components/Button"; +import Badge from "@components/Badge"; +import { notify } from "@components/Notification"; +import SettingsIcon from "@/assets/icons/SettingsIcon"; +import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar"; +import { NotificationEventTypes } from "@/cloud/notifications/NotificationEventTypes"; +import { + NotificationChannel, + NotificationEmailChannel as EmailTarget, + NotificationEventType, +} from "@/interfaces/NotificationChannel"; +import { useNotifications } from "@/cloud/notifications/NotificationProvider"; +import { useUsers } from "@/contexts/UsersProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; + +type Props = { + channel: NotificationChannel; +}; + +export const NotificationEmailChannel = ({ channel }: Props) => { + const { updateChannel, toggleType } = useNotifications(); + const { users } = useUsers(); + const { permission } = usePermissions(); + const canUpdate = permission?.settings?.update ?? false; + const [emailInput, setEmailInput] = useState(""); + const emailInputRef = useRef(null); + + const target = channel.target as EmailTarget | undefined; + const emails = target?.emails ?? []; + + const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput.trim()); + + const handleToggleEnabled = (enabled: boolean) => { + updateChannel({ ...channel, enabled }); + }; + + const handleAddEmail = () => { + const trimmed = emailInput.trim(); + if (!trimmed || emails.includes(trimmed)) return; + notify({ + title: "Email Notifications", + description: `${trimmed} has been successfully added`, + promise: updateChannel({ + ...channel, + target: { emails: [...emails, trimmed] }, + }), + loadingTitle: "Email Notifications", + loadingMessage: `Adding ${trimmed}...`, + }); + setEmailInput(""); + }; + + const handleRemoveEmail = (email: string) => { + const remaining = emails.filter((e) => e !== email); + notify({ + title: "Email Notifications", + description: `${email} has been successfully removed`, + promise: updateChannel({ + ...channel, + target: remaining.length > 0 ? { emails: remaining } : undefined, + }), + loadingTitle: "Email Notifications", + loadingMessage: `Removing ${email}...`, + }); + }; + + const handleToggleType = (eventType: NotificationEventType) => { + toggleType(channel, eventType); + }; + + return ( +
+ + } + /> + } + /> + } + active + /> + +
+
+

Email

+
+
+
+ + + Enable Email Channel + + } + helpText={ + "Enable or disable all email notifications for your account" + } + /> +
+ + + Add one or more email addresses that should receive notifications + +
+ setEmailInput(e.target.value)} + data-testid="notification-email-input" + onKeyDown={(e) => { + if (e.key === "Enter" && isValidEmail) { + e.preventDefault(); + handleAddEmail(); + } + }} + /> + +
+ + {emails.length > 0 && ( +
+ {emails.map((email) => ( + handleRemoveEmail(email) : undefined} + data-testid="notification-email-recipient" + > + u.email === email)?.name || email} + size={"sm"} + /> + {email} + {canUpdate && ( + + )} + + ))} +
+ )} +
+ +
+
+ ); +}; diff --git a/src/cloud/notifications/channels/NotificationSlackChannel.tsx b/src/cloud/notifications/channels/NotificationSlackChannel.tsx new file mode 100644 index 0000000..e9a9ebd --- /dev/null +++ b/src/cloud/notifications/channels/NotificationSlackChannel.tsx @@ -0,0 +1,208 @@ +import * as React from "react"; +import { useState } from "react"; +import Breadcrumbs from "@components/Breadcrumbs"; +import Button from "@components/Button"; +import Card from "@components/Card"; +import TruncatedText from "@components/ui/TruncatedText"; +import { cn } from "@utils/helpers"; +import { + MessageSquareDot, + Link2Off, + MoreVertical, + Repeat, +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; +import { notify } from "@components/Notification"; +import SettingsIcon from "@/assets/icons/SettingsIcon"; +import SlackIcon from "@/assets/icons/SlackIcon"; +import { NotificationEventTypes } from "@/cloud/notifications/NotificationEventTypes"; +import { + NotificationChannel, + NotificationEventType, + NotificationWebhookChannel as SlackTarget, +} from "@/interfaces/NotificationChannel"; +import { useNotifications } from "@/cloud/notifications/NotificationProvider"; +import { useDialog } from "@/contexts/DialogProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import NotificationSlackModal from "@/cloud/notifications/channels/NotificationSlackModal"; + +type Props = { + channel: NotificationChannel; +}; + +export const NotificationSlackChannel = ({ channel }: Props) => { + const { updateChannel, toggleType } = useNotifications(); + const { confirm } = useDialog(); + const { permission } = usePermissions(); + const canUpdate = permission?.settings?.update ?? false; + const [modalOpen, setModalOpen] = useState(false); + + const target = channel.target as SlackTarget | undefined; + const isConnected = !!target?.url; + + const handleSave = (newTarget: SlackTarget) => { + const isNew = !isConnected; + notify({ + title: "Slack Notifications", + description: isNew + ? "Slack has been successfully connected." + : "Slack configuration has been successfully updated.", + promise: updateChannel({ ...channel, enabled: true, target: newTarget }), + loadingMessage: isNew ? "Connecting Slack..." : "Updating Slack...", + }); + }; + + const handleDisconnect = async () => { + const choice = await confirm({ + title: "Disconnect Slack", + description: + "Are you sure you want to disconnect Slack? You will no longer receive notifications in your Slack channel.", + confirmText: "Disconnect", + cancelText: "Cancel", + type: "danger", + }); + if (!choice) return; + notify({ + title: "Slack Notifications", + description: "Slack has been successfully disconnected.", + promise: updateChannel({ + ...channel, + enabled: false, + target: undefined, + }), + loadingMessage: "Disconnecting Slack...", + }); + }; + + const handleToggleType = (eventType: NotificationEventType) => { + toggleType(channel, eventType); + }; + + return ( +
+ + } + /> + } + /> + } + active + /> + +
+
+

Slack

+
+
+
+ +
+ +
+
+
+
+

Slack

+ {isConnected ? ( + + ) : ( + + Not Connected + + )} +
+ {isConnected ? ( + + + + + + +
+ + Disconnect +
+
+
+
+ ) : ( + + )} +
+ + +
+ + +
+ ); +}; diff --git a/src/cloud/notifications/channels/NotificationSlackModal.tsx b/src/cloud/notifications/channels/NotificationSlackModal.tsx new file mode 100644 index 0000000..cc6096e --- /dev/null +++ b/src/cloud/notifications/channels/NotificationSlackModal.tsx @@ -0,0 +1,231 @@ +import Button from "@components/Button"; +import InlineLink from "@components/InlineLink"; +import { Input } from "@components/Input"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, +} from "@components/modal/Modal"; +import Steps from "@components/Steps"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import { Mark } from "@components/ui/Mark"; +import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"; +import { cn, validator } from "@utils/helpers"; +import { isEmpty } from "lodash"; +import { + ExternalLinkIcon, + GlobeIcon, + PlusCircle, + Repeat, +} from "lucide-react"; +import React, { useMemo, useState } from "react"; +import slackImage from "@/assets/integrations/slack.png"; +import { NotificationWebhookChannel as SlackTarget } from "@/interfaces/NotificationChannel"; +import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (target: SlackTarget) => void; +}; + +export default function NotificationSlackModal({ + open, + onOpenChange, + onSave, +}: Readonly) { + return ( + + {open && ( + { + onSave(target); + onOpenChange(false); + }} + /> + )} + + ); +} + +type ModalContentProps = { + onSave: (target: SlackTarget) => void; +}; + +function SlackModalContent({ onSave }: Readonly) { + const [step, setStep] = useState(0); + const [url, setUrl] = useState(""); + + const urlError = useMemo(() => { + if (url === "") return ""; + if (!validator.isValidUrl(url)) { + return "Please enter a valid url, e.g., https://hooks.slack.com/services/..."; + } + return ""; + }, [url]); + + const canConnect = !isEmpty(url) && urlError === ""; + + const handleConnect = () => { + onSave({ url }); + }; + + const maxSteps = 2; + + return ( + step > 0 && e.preventDefault()} + onInteractOutside={(e) => step > 0 && e.preventDefault()} + onPointerDownOutside={(e) => step > 0 && e.preventDefault()} + > + + +
+ {Array.from({ length: maxSteps }).map((_, index) => ( +
= index + 1 && "bg-netbird", + )} + /> + ))} +
+ + + + {step === 0 && ( +
+

+ + Create a Slack App +

+ + + +

+ Open{" "} + + Slack App Management + + {" "} + click Create an app
+ and choose From scratch +

+
+ +

+ Set the app name to{" "} + NetBird Notifications and select your + workspace. After that click Create App +

+
+
+
+ )} + + {step === 1 && ( +
+

+ + Configure Incoming Webhook +

+ + + +

+ In the app settings, go to Incoming Webhooks and + toggle Activate Incoming Webhooks to On +

+
+ +

+ Click Add New Webhook and select the channel where + you want to receive notifications and confirm with{" "} + Allow +

+
+ +

+ Copy the generated Webhook URL and paste it below. +

+
+
+ +
+ + +
+ } + placeholder={"https://hooks.slack.com/services/T000/B000/XXXX"} + value={url} + error={urlError} + onChange={(e) => setUrl(e.target.value)} + data-testid="slack-webhook-url-input" + /> +
+
+ )} + + + {step === 0 && ( + + + + )} + {step > 0 && ( + + )} + {step < maxSteps - 1 && ( + + )} + {step === maxSteps - 1 && ( + + )} + +
+ ); +} diff --git a/src/cloud/notifications/channels/NotificationWebhookChannel.tsx b/src/cloud/notifications/channels/NotificationWebhookChannel.tsx new file mode 100644 index 0000000..d66c3da --- /dev/null +++ b/src/cloud/notifications/channels/NotificationWebhookChannel.tsx @@ -0,0 +1,211 @@ +import * as React from "react"; +import { useState } from "react"; +import Breadcrumbs from "@components/Breadcrumbs"; +import Button from "@components/Button"; +import Card from "@components/Card"; +import TruncatedText from "@components/ui/TruncatedText"; +import { cn } from "@utils/helpers"; +import { + GlobeIcon, + MessageSquareDot, + MoreVertical, + Repeat, + SquarePen, + Trash2, +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; +import { notify } from "@components/Notification"; +import SettingsIcon from "@/assets/icons/SettingsIcon"; +import { NotificationEventTypes } from "@/cloud/notifications/NotificationEventTypes"; +import { + NotificationChannel, + NotificationEventType, + NotificationWebhookChannel as WebhookTarget, +} from "@/interfaces/NotificationChannel"; +import { useNotifications } from "@/cloud/notifications/NotificationProvider"; +import { useDialog } from "@/contexts/DialogProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import NotificationWebhookModal from "@/cloud/notifications/channels/NotificationWebhookModal"; + +type Props = { + channel: NotificationChannel; +}; + +export const NotificationWebhookChannel = ({ channel }: Props) => { + const { updateChannel, toggleType } = useNotifications(); + const { confirm } = useDialog(); + const { permission } = usePermissions(); + const canUpdate = permission?.settings?.update ?? false; + const [modalOpen, setModalOpen] = useState(false); + + const target = channel.target as WebhookTarget | undefined; + const isConnected = !!target?.url; + + const handleSave = (newTarget: WebhookTarget) => { + const isNew = !isConnected; + notify({ + title: "Webhook Notifications", + description: isNew + ? "Webhook has been successfully connected." + : "Webhook configuration has been successfully updated.", + promise: updateChannel({ ...channel, enabled: true, target: newTarget }), + loadingMessage: isNew ? "Connecting webhook..." : "Updating webhook...", + }); + }; + + const handleDeleteConnection = async () => { + const choice = await confirm({ + title: "Delete Webhook Connection", + description: + "Are you sure you want to delete this webhook connection? This action cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel", + type: "danger", + }); + if (!choice) return; + notify({ + title: "Webhook Notifications", + description: "Webhook connection has been successfully deleted.", + promise: updateChannel({ + ...channel, + enabled: false, + target: undefined, + }), + loadingMessage: "Deleting webhook...", + }); + }; + + const handleToggleType = (eventType: NotificationEventType) => { + toggleType(channel, eventType); + }; + + return ( +
+ + } + /> + } + /> + } + active + /> + +
+
+

Webhook

+
+
+
+ +
+ +
+
+
+
+

Webhook

+ {isConnected ? ( + + ) : ( + + Not Connected + + )} +
+ {isConnected ? ( + + + + + + setModalOpen(true)} disabled={!canUpdate} data-testid="webhook-edit"> +
+ + Edit +
+
+ +
+ + Delete +
+
+
+
+ ) : ( + + )} +
+ + +
+ + +
+ ); +}; diff --git a/src/cloud/notifications/channels/NotificationWebhookModal.tsx b/src/cloud/notifications/channels/NotificationWebhookModal.tsx new file mode 100644 index 0000000..f3b49ac --- /dev/null +++ b/src/cloud/notifications/channels/NotificationWebhookModal.tsx @@ -0,0 +1,203 @@ +import React, { useMemo, useState } from "react"; +import Button from "@components/Button"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import { Tabs, TabsList, TabsTrigger } from "@components/Tabs"; +import { + ExternalLinkIcon, + FileCode2Icon, + GlobeIcon, + Repeat, + TextIcon, +} from "lucide-react"; +import { + NOTIFICATION_CHANNELS_WEBHOOK_DOCS_LINK, + NotificationChannel, + NotificationWebhookChannel as WebhookTarget, +} from "@/interfaces/NotificationChannel"; +import Paragraph from "@components/Paragraph"; +import InlineLink from "@components/InlineLink"; +import { useWebhookConfig } from "@/cloud/webhooks/useWebhookConfig"; +import { WebhookGeneralTabContent } from "@/cloud/webhooks/WebhookGeneralTabContent"; +import { WebhookHeadersTabContent } from "@/cloud/webhooks/WebhookHeadersTabContent"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + channel: NotificationChannel; + onSave: (target: WebhookTarget) => void; +}; + +export default function NotificationWebhookModal({ + open, + onOpenChange, + channel, + onSave, +}: Readonly) { + return ( + + {open && ( + { + onSave(target); + onOpenChange(false); + }} + /> + )} + + ); +} + +type ModalContentProps = { + channel: NotificationChannel; + onSave: (target: WebhookTarget) => void; +}; + +function NotificationWebhookModalContent({ + channel, + onSave, +}: Readonly) { + const target = channel.target as WebhookTarget | undefined; + + const config = useWebhookConfig({ + initialUrl: target?.url, + initialHeaders: target?.headers, + }); + + const [tab, setTab] = useState("general"); + const modalWidth = useMemo( + () => (tab === "general" ? "max-w-xl" : "max-w-2xl"), + [tab], + ); + + const handleSave = () => { + onSave({ + url: config.url, + headers: config.formatHeaders(), + }); + }; + + return ( + + } + title={config.isEditing ? "Webhook Configuration" : "Connect Webhook"} + description={ + config.isEditing + ? "Update your webhook endpoint and authentication settings." + : "Configure a webhook endpoint to receive notification events." + } + /> + + setTab(v)}> + + + + General + + + + Headers + + + + + + + +
+ +
+ + Learn more about + + Webhook Notifications + + + +
+
+ {config.isEditing ? ( + <> + + + + + + ) : ( + <> + {tab === "general" && ( + <> + + + + + + )} + {tab === "headers" && ( + <> + + + + )} + + )} +
+
+
+ ); +} diff --git a/src/cloud/reverse-proxy/TerminatedProxiesProvider.tsx b/src/cloud/reverse-proxy/TerminatedProxiesProvider.tsx new file mode 100644 index 0000000..24319ec --- /dev/null +++ b/src/cloud/reverse-proxy/TerminatedProxiesProvider.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import Badge from "@components/Badge"; +import FullTooltip from "@components/FullTooltip"; +import { AlertTriangle } from "lucide-react"; +import InlineLink from "@components/InlineLink"; +import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; + +export function TerminatedProxiesProvider() { + const { reverseProxies } = useReverseProxies(); + const terminated = reverseProxies?.filter((p) => p?.terminated) ?? []; + + return terminated?.map((proxy) => ( + + )); +} + +const DISABLED_SELECTORS = [ + "[data-active-cell]", + "[data-auth-cell]", + "[data-targets-cell]", + "[data-cluster-cell]", + "[data-name-cell]", +]; + +const ACTION_SELECTORS = [ + "data-proxy-edit-action", + "data-proxy-settings-action", +]; + +const blockEvent = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); +}; + +function disableElement(el: HTMLElement) { + el.style.pointerEvents = "none"; + el.style.opacity = "0.5"; + el.addEventListener("click", blockEvent, true); +} + +function enableElement(el: HTMLElement) { + el.style.pointerEvents = ""; + el.style.opacity = ""; + el.removeEventListener("click", blockEvent, true); +} + +const terminatedBadge = ( + + This service has been terminated by the NetBird team as it violates the + Terms of Service. For questions, please contact{" "} + + support@netbird.io + + + } + interactive={true} + > + + + Terminated + + +); + +function findAndDisableAll(proxyId: string) { + const disabled: HTMLElement[] = []; + const statusCells: Element[] = []; + let row: HTMLElement | null = null; + let actionTd: HTMLElement | null = null; + + try { + row = document.querySelector( + `[data-row-id="${proxyId}"]`, + ); + + if (row) { + row.style.pointerEvents = "none"; + row.style.cursor = "default"; + + actionTd = row.querySelector("td:last-child"); + if (actionTd) actionTd.style.pointerEvents = "auto"; + } + + for (const sel of DISABLED_SELECTORS) { + const el = row?.querySelector(sel); + if (el) { + disableElement(el); + disabled.push(el); + } + } + + const mainStatusCell = row?.querySelector("[data-status-cell]"); + if (mainStatusCell) { + statusCells.push(mainStatusCell); + mainStatusCell.style.pointerEvents = "auto"; + } + + const flatCells = document.querySelectorAll( + `[data-proxy-id="${proxyId}"]`, + ); + for (const flatCell of flatCells) { + disableElement(flatCell); + disabled.push(flatCell); + const statusCell = + flatCell.querySelector("[data-status-cell]") ?? + flatCell.parentElement?.querySelector("[data-status-cell]"); + if (statusCell) statusCells.push(statusCell); + } + + for (const attr of ACTION_SELECTORS) { + const items = document.querySelectorAll( + `[${attr}="${proxyId}"]`, + ); + for (const item of items) { + disableElement(item); + disabled.push(item); + } + } + } catch (e) {} + + return { disabled, statusCells, row, actionTd }; +} + +function TerminatedPortal({ proxyId }: { proxyId: string }) { + const [statusTargets, setStatusTargets] = useState([]); + + useEffect(() => { + const tracked = new Set(); + let trackedRow: HTMLElement | null = null; + let trackedActionTd: HTMLElement | null = null; + + const apply = () => { + const { disabled, statusCells, row, actionTd } = + findAndDisableAll(proxyId); + for (const el of disabled) tracked.add(el); + if (row) trackedRow = row; + if (actionTd) trackedActionTd = actionTd; + if (statusCells.length > 0) { + setStatusTargets((prev) => { + const prevSet = new Set(prev); + const newCells = statusCells.filter((c) => !prevSet.has(c)); + return newCells.length > 0 ? [...prev, ...newCells] : prev; + }); + } + }; + + apply(); + + const observer = new MutationObserver(() => { + try { + apply(); + } catch (e) {} + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + for (const el of tracked) { + try { + enableElement(el); + } catch (e) {} + } + if (trackedRow) { + trackedRow.style.pointerEvents = ""; + trackedRow.style.cursor = ""; + } + if (trackedActionTd) { + trackedActionTd.style.pointerEvents = ""; + } + }; + }, [proxyId]); + + if (statusTargets.length === 0) return null; + + return statusTargets.map((target, i) => + createPortal(terminatedBadge, target, `${proxyId}-${i}`), + ); +} diff --git a/src/cloud/settings/CloudSettings.tsx b/src/cloud/settings/CloudSettings.tsx new file mode 100644 index 0000000..be9949a --- /dev/null +++ b/src/cloud/settings/CloudSettings.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; +import { + PlansAndBillingTab, + PlansAndBillingTabTrigger, +} from "@/modules/billing/PlansAndBillingTab"; +import { InvoicesTab, InvoicesTabTrigger } from "@/cloud/invoices/InvoicesTab"; +import { + NotificationsTabTrigger, + NotificationTab, +} from "@/cloud/notifications/NotificationTab"; + +export const CloudSettingsTabContent = () => { + return ( + <> + + + + + ); +}; + +export const CloudSettingsTabTrigger = () => { + return ( + <> + + + + + ); +}; diff --git a/src/cloud/survey/HowDidYouHearAboutUs.tsx b/src/cloud/survey/HowDidYouHearAboutUs.tsx new file mode 100644 index 0000000..485eca9 --- /dev/null +++ b/src/cloud/survey/HowDidYouHearAboutUs.tsx @@ -0,0 +1,141 @@ +import Button from "@components/Button"; +import { Label } from "@components/Label"; +import { Modal, ModalContent } from "@components/modal/Modal"; +import Paragraph from "@components/Paragraph"; +import { SelectDropdown } from "@components/select/SelectDropdown"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import { useLocalStorage } from "@hooks/useLocalStorage"; +import loadConfig from "@utils/config"; +import { cn } from "@utils/helpers"; +import dayjs from "dayjs"; +import { useSearchParams } from "next/navigation"; +import React, { useEffect, useMemo, useState } from "react"; +import { submitHubspotForm } from "@/cloud/analytics/Hubspot"; +import { HubspotFormField } from "@/contexts/AnalyticsProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { useAccount } from "@/modules/account/useAccount"; +import { referralSourceOptions } from "@/modules/onboarding/OnboardingSurvey"; + +export default function HowDidYouHearAboutUs() { + const { isOwner, loggedInUser } = useLoggedInUser(); + const account = useAccount(); + const params = useSearchParams(); + const hsId = params?.get("hs_id") ?? ""; + const gaId = params?.get("ga_id") ?? ""; + + const [open, setOpen] = useState(false); + const [isPending, setIsPending] = useLocalStorage( + "netbird-survey-pending", + true, + ); + + const [referralSource, setReferralSource] = useState(""); + + const submitForm = async () => { + setIsPending(false); + setOpen(false); + let fields: HubspotFormField[] = []; + try { + if (loggedInUser) { + fields = [ + { + name: "email", + value: loggedInUser?.email || "", + }, + { + name: "how_did_you_hear_about_us", + value: referralSource || "Not specified", + }, + ]; + } + await submitHubspotForm({ + id: loadConfig().hubspotSurveyFormId ?? "", + fields, + hubspotQueryId: hsId, + gaId, + }); + } catch (e) { + console.log(e); + } + }; + + useEffect(() => { + if (!account) return; + + const createdAt = dayjs(account.created_at); + const cutoffDate = dayjs("2025-07-04T18:00:00"); // Only accounts created before this date will see the modal + const expiryDate = dayjs("2025-07-10T23:59:59"); // Modal is valid until this date + + const isCreatedBeforeToday = createdAt.isBefore(cutoffDate); + const isBeforeExpiryDate = dayjs().isBefore(expiryDate); + + if (isCreatedBeforeToday && isBeforeExpiryDate && isPending && isOwner) { + setOpen(true); + } + }, [account, isOwner, isPending]); + + const randomizedOptions = useMemo(() => { + return referralSourceOptions.sort(() => Math.random() - 0.5); + }, []); + + return ( + process.env.APP_ENV !== "test" && ( + { + setOpen(v); + if (!v) { + setIsPending(false); + } + }} + > + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + > + +
+

+ We’d love to hear from you +

+ + Help us improve by sharing how you discovered NetBird. Your + feedback truly helps us grow. + +
+
+
+ + +
+ + +
+
+
+ ) + ); +} diff --git a/src/cloud/traffic-events/TrafficEventSetting.tsx b/src/cloud/traffic-events/TrafficEventSetting.tsx new file mode 100644 index 0000000..617ff69 --- /dev/null +++ b/src/cloud/traffic-events/TrafficEventSetting.tsx @@ -0,0 +1,278 @@ +import Button from "@components/Button"; +import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import HelpText from "@components/HelpText"; +import InlineLink from "@components/InlineLink"; +import { Label } from "@components/Label"; +import { notify } from "@components/Notification"; +import { PeerGroupSelector } from "@components/PeerGroupSelector"; +import { useHasChanges } from "@hooks/useHasChanges"; +import { useApiCall } from "@utils/api"; +import { cn } from "@utils/helpers"; +import { + ArrowLeftRightIcon, + ExternalLinkIcon, + FlaskConicalIcon, +} from "lucide-react"; +import * as React from "react"; +import { useState } from "react"; +import Skeleton from "react-loading-skeleton"; +import { useSWRConfig } from "swr"; +import { useDialog } from "@/contexts/DialogProvider"; +import { useGroups } from "@/contexts/GroupsProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { Account } from "@/interfaces/Account"; +import { LockedFeatureBadge } from "@/modules/billing/locked-feature/LockedFeatureBadge"; +import useGroupHelper from "@/modules/groups/useGroupHelper"; + +type Props = { + account: Account; +}; + +export const TRAFFIC_EVENTS_DOC_LINK = + "https://docs.netbird.io/how-to/traffic-events-logging"; + +export const TrafficEventSetting = ({ account }: Props) => { + const { permission } = usePermissions(); + const { groups } = useGroups(); + const { mutate } = useSWRConfig(); + const { confirm } = useDialog(); + const saveRequest = useApiCall("/accounts/" + account.id); + + const [trafficEventsEnabled, setTrafficEventsEnabled] = useState( + account.settings?.extra?.network_traffic_logs_enabled ?? false, + ); + + const [trafficPacketCounterEnabled, setTrafficPacketCounterEnabled] = + useState( + account.settings?.extra?.network_traffic_packet_counter_enabled ?? false, + ); + + const toggleTrafficEvents = async (toggle: boolean) => { + if (!toggle) { + setTrafficPacketCounterEnabled(false); + } + notify({ + title: "Traffic Events", + description: `Traffic events successfully ${ + toggle ? "enabled" : "disabled" + }.`, + promise: saveRequest + .put({ + id: account.id, + settings: { + ...account.settings, + extra: { + ...account.settings?.extra, + network_traffic_logs_enabled: toggle, + network_traffic_packet_counter_enabled: !toggle + ? false + : trafficPacketCounterEnabled, + }, + }, + }) + .then(() => { + setTrafficEventsEnabled(toggle); + mutate("/accounts"); + }), + loadingMessage: "Updating traffic events setting...", + }); + }; + + const toggleTrafficPacketCounter = async (toggle: boolean) => { + let choice = false; + if (toggle) { + choice = await confirm({ + title: "Enable Traffic Reporting (Kernel)?", + description: + "Note: Enabling this setting will lead to a higher CPU usage than usual on the NetBird client.", + confirmText: "Enable", + cancelText: "Cancel", + type: "default", + }); + if (!choice) return; + } + + notify({ + title: "Traffic Reporting", + description: `Traffic reporting successfully ${ + toggle ? "enabled" : "disabled" + }.`, + promise: saveRequest + .put({ + id: account.id, + settings: { + ...account.settings, + extra: { + ...account.settings?.extra, + network_traffic_packet_counter_enabled: toggle, + }, + }, + }) + .then(() => { + setTrafficPacketCounterEnabled(toggle); + mutate("/accounts"); + }), + loadingMessage: "Updating traffic reporting setting...", + }); + }; + + return ( + <> +
+

+ Experimental + +

+
+ Traffic events is an experimental feature. Functionality and behavior + may evolve, including changes to how data is collected or reported. + Traffic events data retention is limited to 48 hours and capped at a + maximum of 50,000 events.{" "} + + Learn more + + +
+
+
+ +
+ + + Enable Traffic Events + + } + helpText={ + <> + Enable traffic events for all peers. This requires NetBird + client v0.39 or higher. + + } + disabled={!permission.settings.update} + /> + +
+ Enable Traffic Reporting (Kernel)} + helpText={ + <> + Traffic reporting is always enabled in userspace, and this + setting only applies to kernel. If enabled, network packets + and their size will be counted and reported. + + } + disabled={!permission.settings.update} + /> +
+ + + Select peer groups for which traffic events will be logged.{" "} +
+ If no group is selected, logging applies to all peers. +
+ {!groups ? ( + + ) : ( + + )} +
+
+
+
+
+ + ); +}; + +type TrafficEventGroupsSettingProps = { + account: Account; +}; + +export const TrafficEventGroupsSetting = ({ + account, +}: TrafficEventGroupsSettingProps) => { + const saveRequest = useApiCall("/accounts/" + account.id); + const { mutate } = useSWRConfig(); + + const [trafficGroups, setTrafficGroups, { save: saveGroups }] = + useGroupHelper({ + initial: account.settings?.extra?.network_traffic_logs_groups, + }); + + const { hasChanges, updateRef } = useHasChanges([trafficGroups]); + + const saveTrafficGroups = async () => { + const groups = await saveGroups(); + const groupIds = groups.map((group) => group.id) as string[]; + + notify({ + title: "Traffic Events Groups", + description: "Traffic events groups successfully updated.", + promise: saveRequest + .put({ + id: account.id, + settings: { + ...account.settings, + extra: { + ...account.settings?.extra, + network_traffic_logs_groups: groupIds || [], + }, + }, + }) + .then(() => { + setTrafficGroups(groups); + updateRef([groups]); + mutate("/accounts"); + }), + loadingMessage: "Updating traffic events groups...", + }); + }; + + return ( +
+ + +
+ ); +}; diff --git a/src/cloud/traffic-events/TrafficEventsConnectionTypeFilter.tsx b/src/cloud/traffic-events/TrafficEventsConnectionTypeFilter.tsx new file mode 100644 index 0000000..63fcbec --- /dev/null +++ b/src/cloud/traffic-events/TrafficEventsConnectionTypeFilter.tsx @@ -0,0 +1,35 @@ +import ButtonGroup from "@components/ButtonGroup"; +import * as React from "react"; + +type Props = { + value?: string; + onChange?: (value: string) => void; +}; + +export const TrafficEventsConnectionTypeFilter = ({ + value, + onChange, +}: Props) => { + return ( + + onChange?.("")} + variant={value == undefined || value == "" ? "tertiary" : "secondary"} + > + All + + onChange?.("P2P")} + variant={value === "P2P" ? "tertiary" : "secondary"} + > + P2P + + onChange?.("ROUTED")} + variant={value === "ROUTED" ? "tertiary" : "secondary"} + > + Routed + + + ); +}; diff --git a/src/cloud/traffic-events/TrafficEventsFilter.tsx b/src/cloud/traffic-events/TrafficEventsFilter.tsx new file mode 100644 index 0000000..e75e678 --- /dev/null +++ b/src/cloud/traffic-events/TrafficEventsFilter.tsx @@ -0,0 +1,451 @@ +import Button from "@components/Button"; +import { Checkbox } from "@components/Checkbox"; +import { DropdownInfoText } from "@components/DropdownInfoText"; +import { DropdownInput } from "@components/DropdownInput"; +import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover"; +import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList"; +import { useSearch } from "@hooks/useSearch"; +import { Table } from "@tanstack/react-table"; +import { cn } from "@utils/helpers"; +import { FilterIcon, Share2Icon } from "lucide-react"; +import * as React from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + getTrafficEventProtocol, + TrafficEvent, + TrafficEventDirection, + TrafficEventType, +} from "@/cloud/traffic-events/interfaces/TrafficEvent"; +import { TrafficEventProtocol } from "@/cloud/traffic-events/interfaces/TrafficEventProtocol"; +import { getTrafficEventTypeText } from "@/cloud/traffic-events/TrafficEventsTable"; + +interface Props { + table: Table; + disabled?: boolean; + filters?: { + type?: string[]; + direction?: string[]; + protocol?: string[]; + }; + onFilterChange?: (filters: { + type?: string[]; + direction?: string[]; + protocol?: string[]; + }) => void; + closeOnSelect?: boolean; +} + +interface CombinedFilter { + id: string; + displayText: string; + type?: string; + connection_type?: string; + direction?: string; + protocol?: string; +} + +const searchPredicate = (item: any, query: any) => { + const lowerCaseQuery = query.toLowerCase(); + let itemValue = String(item?.displayText || "").toLowerCase(); + return itemValue.includes(lowerCaseQuery); +}; + +export interface FixedFilterItem { + id: string; + columnId: keyof TrafficEvent | string; + value: string; + filterType: "type" | "protocol"; + filterValue: CombinedFilter; + displayText: string; + item: () => string | React.ReactNode; +} + +const getCombinedFilterId = ( + type?: string, + direction?: string, + protocol?: string, +): string => { + return `${type || ""}|${direction || ""}|${protocol || ""}`; +}; + +export function TrafficEventsFilter({ + table, + disabled = false, + filters = {}, + onFilterChange, + closeOnSelect = false, +}: Readonly) { + const filterItems = useMemo(() => getFixedTypeFilters(), []); + const searchRef = React.useRef(null); + const [open, setOpen] = useState(false); + const [activeFilterIds, setActiveFilterIds] = useState([]); + + const updateTableFilters = useCallback( + (activeIds: string[]) => { + const typeColumn = table.getColumn("type"); + const protocolColumn = table.getColumn("protocol"); + + if (typeColumn) { + const typeItems = filterItems.filter( + (item) => activeIds.includes(item.id) && item.filterType === "type", + ); + const typeValues = typeItems.map((item) => item.value); + + if (typeValues.length > 0) { + typeColumn.setFilterValue(typeValues); + } else { + typeColumn.setFilterValue(undefined); + } + } + + if (protocolColumn) { + const protocolItems = filterItems.filter( + (item) => + activeIds.includes(item.id) && item.filterType === "protocol", + ); + const protocolValues = protocolItems.map((item) => item.value); + + if (protocolValues.length > 0) { + protocolColumn.setFilterValue(protocolValues); + } else { + protocolColumn.setFilterValue(undefined); + } + } + }, + [table, filterItems], + ); + + useEffect(() => { + const activeIds: string[] = []; + + if ( + !filters.type?.length && + !filters.direction?.length && + !filters.protocol?.length + ) { + setActiveFilterIds([]); + updateTableFilters([]); + return; + } + + filterItems.forEach((item) => { + const filter = item.filterValue; + let isMatch = true; + + if (filters.type && filter.type) { + isMatch = isMatch && filters.type.includes(filter.type); + } + + if (filters.direction && filter.direction) { + isMatch = isMatch && filters.direction.includes(filter.direction); + } + + if (filters.protocol && filter.protocol) { + isMatch = isMatch && filters.protocol.includes(filter.protocol); + } + + if ( + isMatch && + (filter.type || + filter.connection_type || + filter.direction || + filter.protocol) + ) { + activeIds.push(item.id); + } + }); + + setActiveFilterIds(activeIds); + updateTableFilters(activeIds); + }, [filters, filterItems, updateTableFilters]); + + const [filteredItems, search, setSearch] = useSearch( + filterItems, + searchPredicate, + { + filter: true, + debounce: 500, + }, + ); + + const onOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + + if (!isOpen) { + setTimeout(() => { + setSearch(""); + }, 100); + } + }; + + const onSelect = (item: FixedFilterItem) => { + table.setPageIndex(0); + + let newActiveFilterIds; + if (activeFilterIds.includes(item.id)) { + newActiveFilterIds = activeFilterIds.filter((id) => id !== item.id); + } else { + newActiveFilterIds = [...activeFilterIds, item.id]; + } + + setActiveFilterIds(newActiveFilterIds); + updateTableFilters(newActiveFilterIds); + + if (onFilterChange) { + const selectedFilters = newActiveFilterIds + .map((id) => { + return filterItems.find((item) => item.id === id)?.filterValue; + }) + .filter(Boolean) as CombinedFilter[]; + + const typeFilters = new Set(); + const connectionTypeFilters = new Set(); + const directionFilters = new Set(); + const protocolFilters = new Set(); + + selectedFilters.forEach((filter) => { + if (filter.type) typeFilters.add(filter.type); + if (filter.connection_type) + connectionTypeFilters.add(filter.connection_type); + if (filter.direction) directionFilters.add(filter.direction); + if (filter.protocol) protocolFilters.add(filter.protocol); + }); + + const newFilters = { + type: typeFilters.size > 0 ? Array.from(typeFilters) : undefined, + connection_type: + connectionTypeFilters.size > 0 + ? Array.from(connectionTypeFilters) + : undefined, + direction: + directionFilters.size > 0 ? Array.from(directionFilters) : undefined, + protocol: + protocolFilters.size > 0 ? Array.from(protocolFilters) : undefined, + }; + + onFilterChange(newFilters); + } + + if (closeOnSelect) { + setOpen(false); + } else { + searchRef.current?.focus(); + } + }; + + return ( + + + + + { + const target = e.target as Node; + const trigger = document.querySelector( + '[data-state="open"][aria-expanded="true"]', + ); + if (trigger && trigger.contains(target)) { + return; + } + setOpen(false); + }} + > +
+ + + {filteredItems.length == 0 && search != "" && ( + + There are no filters matching your search. + + )} + + { + const isActive = activeFilterIds.includes(option.id); + + return ( +
+
+
{option?.item()}
+
+ +
+ ); + }} + onSelect={onSelect} + /> +
+
+
+ ); +} + +const getFixedTypeFilters = () => { + let directions = [ + TrafficEventDirection.EGRESS, + TrafficEventDirection.INGRESS, + ]; + let types = [ + TrafficEventType.CONNECTED, + TrafficEventType.STOPPED, + TrafficEventType.BLOCKED, + ]; + + const filters: FixedFilterItem[] = []; + + for (const t of types) { + for (const d of directions) { + const eventTypeText = + t === TrafficEventType.CONNECTED + ? "started" + : t === TrafficEventType.STOPPED + ? "stopped" + : "blocked"; + const directionText = + d === TrafficEventDirection.INGRESS ? "(inbound)" : "(outbound)"; + + const displayText = `Connection ${eventTypeText} ${directionText}`; + + const combinedFilter: CombinedFilter = { + id: getCombinedFilterId(t, d), + displayText, + type: t, + direction: d, + }; + + filters.push({ + id: combinedFilter.id, + columnId: "type", + value: displayText, + filterType: "type" as const, + filterValue: combinedFilter, + displayText, + item: () => ( +
+
+ +
+ {displayText} +
+ ), + }); + } + } + + let protocols: (keyof typeof TrafficEventProtocol)[] = [ + 6, // TCP + 17, // UDP + 1, // ICMP + ]; + + for (const p of protocols) { + const protocolName = getTrafficEventProtocol(p); + + const combinedFilter: CombinedFilter = { + id: getCombinedFilterId(undefined, undefined, protocolName), + displayText: protocolName, + protocol: p?.toString(), + }; + + filters.push({ + id: combinedFilter.id, + columnId: "protocol", + value: p?.toString(), + filterType: "protocol" as const, + filterValue: combinedFilter, + displayText: protocolName, + item: () => ( +
+ + {protocolName} +
+ ), + }); + } + + return filters; +}; + +type TrafficEventTypeFilterItemProps = { + t: TrafficEventType; + isP2P: boolean; + direction: TrafficEventDirection; +}; + +const TrafficEventTypeFilterItem = ({ + t, + isP2P, + direction, +}: TrafficEventTypeFilterItemProps) => { + return ( +
+
+ +
+ {getTrafficEventTypeText(t, isP2P, direction)} +
+ ); +}; + +const TrafficEventProtocolFilterItem = ({ + p, +}: { + p: keyof typeof TrafficEventProtocol; +}) => { + const protocolName = getTrafficEventProtocol(p); + return ( +
+ + {protocolName} +
+ ); +}; diff --git a/src/cloud/traffic-events/TrafficEventsInboundOutboundFilter.tsx b/src/cloud/traffic-events/TrafficEventsInboundOutboundFilter.tsx new file mode 100644 index 0000000..56cefa3 --- /dev/null +++ b/src/cloud/traffic-events/TrafficEventsInboundOutboundFilter.tsx @@ -0,0 +1,65 @@ +import { cn } from "@utils/helpers"; +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; +import * as React from "react"; +import { TrafficEventDirection } from "@/cloud/traffic-events/interfaces/TrafficEvent"; + +interface Props { + value: TrafficEventDirection; + onChange: (value: TrafficEventDirection) => void; +} + +export function TrafficEventsInboundOutboundFilter({ value, onChange }: Props) { + const isInbound = value == TrafficEventDirection.INGRESS; + const isOutbound = value == TrafficEventDirection.EGRESS; + + return ( +
+ onChange(TrafficEventDirection.INGRESS)} + > + + Inbound + + onChange(TrafficEventDirection.EGRESS)} + > + + Outbound + +
+ ); +} + +type InnerButtonProps = { + isActive: boolean; + onClick: () => void; + children: React.ReactNode; +}; + +const InnerButton = ({ isActive, onClick, children }: InnerButtonProps) => { + return ( + + ); +}; diff --git a/src/cloud/traffic-events/TrafficEventsPeerTabContent.tsx b/src/cloud/traffic-events/TrafficEventsPeerTabContent.tsx new file mode 100644 index 0000000..86b064e --- /dev/null +++ b/src/cloud/traffic-events/TrafficEventsPeerTabContent.tsx @@ -0,0 +1,480 @@ +import Button from "@components/Button"; +import Card from "@components/Card"; +import { DatePickerWithRange } from "@components/DatePickerWithRange"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable, { + SkeletonTableHeader, +} from "@components/skeletons/SkeletonTable"; +import { DataTable } from "@components/table/DataTable"; +import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; +import DataTableResetFilterButton from "@components/table/DataTableResetFilterButton"; +import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; +import { TabsTrigger } from "@components/Tabs"; +import NoResults from "@components/ui/NoResults"; +import type { SortingState } from "@tanstack/react-table"; +import useFetchApi from "@utils/api"; +import dayjs from "dayjs"; +import { isEqual } from "lodash"; +import { + ArrowLeftRightIcon, + ArrowUpRightIcon, + ExternalLinkIcon, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { Suspense, useMemo, useState } from "react"; +import { DateRange } from "react-day-picker"; +import { useSWRConfig } from "swr"; +import { + TrafficEvent, + TrafficEventDirection, +} from "@/cloud/traffic-events/interfaces/TrafficEvent"; +import { TrafficEventsDetailRow } from "@/cloud/traffic-events/table/TrafficEventsDetailRow"; +import { TrafficEventsInboundOutboundFilter } from "@/cloud/traffic-events/TrafficEventsInboundOutboundFilter"; +import { + TrafficEventsTableColumns, + TrafficEventsTableProps, +} from "@/cloud/traffic-events/TrafficEventsTable"; +import { usePeer } from "@/contexts/PeerProvider"; +import { Pagination } from "@/interfaces/Pagination"; +import { useAccount } from "@/modules/account/useAccount"; +import InlineLink from "@components/InlineLink"; +import { TRAFFIC_EVENTS_DOC_LINK } from "@/cloud/traffic-events/TrafficEventSetting"; + +export const TrafficEventsPeerTabContent = () => { + const account = useAccount(); + const { peer } = usePeer(); + const { mutate } = useSWRConfig(); + const isEnabled = !!account?.settings?.extra?.network_traffic_logs_enabled; + + const peerId = peer?.id || ""; + + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [searchQuery, setSearchQuery] = useState(""); + + const defaultDirection = peer?.user_id + ? TrafficEventDirection.EGRESS + : TrafficEventDirection.INGRESS; + + const [trafficType, setTrafficType] = + useState(defaultDirection); + + const defaultDateRange = { + from: dayjs().subtract(7, "day").startOf("day").toDate(), + to: dayjs().endOf("day").toDate(), + }; + + const [dateRange, setDateRange] = useState( + defaultDateRange, + ); + + const buildApiUrl = ( + p: number, + ps: number, + sq: string, + dir: TrafficEventDirection, + dateFrom?: Date, + dateTo?: Date, + ) => { + let url = `/events/network-traffic?page=${p}&page_size=${ps}&reporter_id=${peerId}${ + sq ? `&search=${encodeURIComponent(sq)}` : "" + }&direction=${dir}`; + + if (dateFrom && dateTo) { + url += `&start_date=${dayjs(dateFrom).format("YYYY-MM-DDTHH:mm:ss[Z]")}`; + url += `&end_date=${dayjs(dateTo).format("YYYY-MM-DDTHH:mm:ss[Z]")}`; + } + + return url; + }; + + const { data: events, isLoading } = useFetchApi>( + buildApiUrl( + page, + pageSize, + searchQuery, + trafficType, + dateRange?.from, + dateRange?.to, + ), + ); + + const isInbound = trafficType === TrafficEventDirection.INGRESS; + + const handlePaginationChange = (pagination: { + pageIndex: number; + pageSize: number; + }) => { + if (pagination.pageSize !== pageSize) { + setPage(1); + setPageSize(pagination.pageSize); + mutate( + buildApiUrl( + 1, + pagination.pageSize, + searchQuery, + trafficType, + dateRange?.from, + dateRange?.to, + ), + ); + } else { + setPage(pagination.pageIndex + 1); + setPageSize(pagination.pageSize); + } + }; + + const handleSearchChange = (value: string) => { + setSearchQuery(value); + setPage(1); + mutate( + buildApiUrl( + 1, + pageSize, + value, + trafficType, + dateRange?.from, + dateRange?.to, + ), + ); + }; + + const handleTrafficTypeChange = (type: TrafficEventDirection) => { + setTrafficType(type); + setPage(1); + mutate( + buildApiUrl( + 1, + pageSize, + searchQuery, + type, + dateRange?.from, + dateRange?.to, + ), + ); + }; + + const handleDateRangeChange = (range: DateRange | undefined) => { + setDateRange(range); + setPage(1); + mutate( + buildApiUrl( + 1, + pageSize, + searchQuery, + trafficType, + range?.from, + range?.to, + ), + ); + }; + + const hasActiveFilters = (): boolean => { + return searchQuery !== "" || !isEqual(dateRange, defaultDateRange); + }; + + const resetAllFilters = () => { + setSearchQuery(""); + setDateRange(defaultDateRange); + setPage(1); + + mutate( + buildApiUrl( + 1, + pageSize, + "", + defaultDirection, + defaultDateRange.from, + defaultDateRange.to, + ), + ); + }; + + const trafficEvents = useMemo(() => { + return events?.data?.map((event) => ({ + ...event, + id: event.flow_id, + })); + }, [events]); + + return ( +
+
+
+
+ + Here you can see all the {isInbound ? "inbound" : "outbound"}{" "} + traffic events for this peer. + + + Learn more about{" "} + + Traffic Events + {" "} + in our documentation. + +
+
+ + + +
+ +
+
+ } + > + + +
+ + ); +}; + +const TrafficEventsPeerDetailTable = ({ + events, + isSettingEnabled, + isLoading, + headingTarget, + trafficType, + setTrafficType, + pagination, + totalRecords, + pageCount, + onPaginationChange, + peerId, + searchQuery, + onSearchChange, + buildApiUrl, + dateRange, + onDateRangeChange, + hasActiveFilters, + onResetFilters, + defaultDirection, + defaultDateRange, + totalEvents, +}: TrafficEventsTableProps & { + trafficType: TrafficEventDirection; + setTrafficType: (value: TrafficEventDirection) => void; + pagination?: { + pageIndex: number; + pageSize: number; + }; + totalRecords?: number; + pageCount?: number; + onPaginationChange?: (pagination: { + pageIndex: number; + pageSize: number; + }) => void; + peerId: string; + searchQuery?: string; + onSearchChange?: (value: string) => void; + buildApiUrl: ( + p: number, + ps: number, + sq: string, + dir: TrafficEventDirection, + dateFrom?: Date, + dateTo?: Date, + ) => string; + dateRange?: DateRange; + onDateRangeChange?: (range: DateRange | undefined) => void; + hasActiveFilters: boolean; + onResetFilters: () => void; + defaultDirection: TrafficEventDirection; + defaultDateRange: DateRange; + totalEvents?: number; +}) => { + const { mutate } = useSWRConfig(); + const router = useRouter(); + + const [sorting, setSorting] = useState([ + { + id: "timestamp", + desc: true, + }, + ]); + + const isInbound = trafficType === TrafficEventDirection.INGRESS; + + const [initialDateRange, setInitialDateRange] = useState< + DateRange | undefined + >(dateRange); + + return ( + { + if (e.events.length < 2) return undefined; + return ; + }} + tableClassName={"mt-0"} + columns={TrafficEventsTableColumns} + keepStateInLocalStorage={false} + data={events} + globalFilter={searchQuery} + onGlobalFilterChange={onSearchChange} + manualFiltering={true} + searchPlaceholder={"Search by ip, port, peer or resource..."} + columnVisibility={{ + user: false, + source: true, + destination: true, + search: false, + source_port: false, + destination_port: false, + bytes_all: true, + bytes_inbound: false, + bytes_outbound: false, + reporter: false, + direction: false, + type: false, + timestamp: false, + policy: false, + }} + isLoading={isLoading} + onFilterReset={() => { + onResetFilters(); + setInitialDateRange(defaultDateRange); + }} + showResetFilterButton={false} + getStartedCard={ + } + > + {!isSettingEnabled && ( +
+ +
+ )} +
+ } + paginationPaddingClassName={"px-0 pt-8"} + pagination={pagination} + onPaginationChange={onPaginationChange} + totalRecords={totalRecords} + pageCount={pageCount} + manualPagination={true} + > + {(table) => ( + <> + { + table.setPageIndex(0); + setTrafficType(type); + }} + /> + + {events && events?.length > 0 && ( + { + setInitialDateRange(range); + table.setPageIndex(0); + onDateRangeChange?.(range); + }} + /> + )} + + + + { + const currentPage = + pagination?.pageIndex !== undefined + ? pagination.pageIndex + 1 + : 1; + const currentPageSize = pagination?.pageSize || 10; + mutate( + buildApiUrl( + currentPage, + currentPageSize, + searchQuery || "", + trafficType, + dateRange?.from, + dateRange?.to, + ), + ).then(); + }} + /> + + + + )} +
+ ); +}; + +export const TrafficEventsPeerTabTrigger = () => { + return ( + + + Traffic Events + + ); +}; diff --git a/src/cloud/traffic-events/TrafficEventsTable.tsx b/src/cloud/traffic-events/TrafficEventsTable.tsx new file mode 100644 index 0000000..c1356fd --- /dev/null +++ b/src/cloud/traffic-events/TrafficEventsTable.tsx @@ -0,0 +1,536 @@ +import Button from "@components/Button"; +import { DatePickerWithRange } from "@components/DatePickerWithRange"; +import InlineLink from "@components/InlineLink"; +import SquareIcon from "@components/SquareIcon"; +import { DataTable } from "@components/table/DataTable"; +import DataTableHeader from "@components/table/DataTableHeader"; +import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; +import DataTableResetFilterButton from "@components/table/DataTableResetFilterButton"; +import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; +import GetStartedTest from "@components/ui/GetStartedTest"; +import NoResults from "@components/ui/NoResults"; +import type { ColumnDef, SortingState } from "@tanstack/react-table"; +import useFetchApi from "@utils/api"; +import dayjs from "dayjs"; +import { ArrowLeftRightIcon, ExternalLinkIcon } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import React, { useCallback, useMemo, useState } from "react"; +import { DateRange } from "react-day-picker"; +import { useSWRConfig } from "swr"; +import { + TrafficEvent, + TrafficEventDirection, + TrafficEventType, +} from "@/cloud/traffic-events/interfaces/TrafficEvent"; +import { TrafficEventsBytesCell } from "@/cloud/traffic-events/table/TrafficEventsBytesCell"; +import { TrafficEventsDetailRow } from "@/cloud/traffic-events/table/TrafficEventsDetailRow"; +import { TrafficEventsMachineCell } from "@/cloud/traffic-events/table/TrafficEventsMachineCell"; +import { TrafficEventsPortCell } from "@/cloud/traffic-events/table/TrafficEventsPortCell"; +import { TrafficEventsReporterCell } from "@/cloud/traffic-events/table/TrafficEventsReporterCell"; +import { TrafficEventsTextCell } from "@/cloud/traffic-events/table/TrafficEventsTextCell"; +import { TrafficEventsTimeCell } from "@/cloud/traffic-events/table/TrafficEventsTimeCell"; +import { TrafficEventsConnectionTypeFilter } from "@/cloud/traffic-events/TrafficEventsConnectionTypeFilter"; +import { TRAFFIC_EVENTS_DOC_LINK } from "@/cloud/traffic-events/TrafficEventSetting"; +import { TrafficEventsFilter } from "@/cloud/traffic-events/TrafficEventsFilter"; +import { parseAddressPort } from "@/cloud/traffic-events/utils/parseAddress"; +import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { + UsersDropdownSelector, + UserSelectOption, +} from "@/modules/activity/UsersDropdownSelector"; + +export type TrafficEventsTableProps = { + events?: TrafficEvent[]; + isLoading: boolean; + headingTarget?: HTMLHeadingElement | null; + isSettingEnabled: boolean; + totalRecords?: number; + totalPages?: number; + onPaginationChange?: (pagination: { + pageIndex: number; + pageSize: number; + }) => void; + pagination?: { pageIndex: number; pageSize: number }; + globalFilter?: string; + onGlobalFilterChange?: (value: string) => void; + onDateFilterChange?: (from?: Date, to?: Date) => void; + dateFrom?: string; + dateTo?: string; + filters?: { + type?: string[]; + connection_type?: string; + direction?: string[]; + protocol?: string[]; + }; + onFilterChange?: (filters: { + type?: string[]; + connection_type?: string; + direction?: string[]; + protocol?: string[]; + }) => void; + users?: any[]; + userId?: string; + onUserFilterChange?: (userId: string) => void; + onResetAllFilters?: () => void; + apiUrl?: string; + connectionTypeFilter?: string; + onConnectionTypeFilterChange?: (value: string) => void; +}; + +export const getTrafficEventTypeText = ( + t: TrafficEventType, + isP2P: boolean, + direction: TrafficEventDirection, +) => { + const name = isP2P ? "P2P" : "Routed"; + const isInbound = direction === TrafficEventDirection.INGRESS; + const directionText = isInbound ? "(inbound)" : "(outbound)"; + + switch (t) { + case TrafficEventType.CONNECTED: + return `${name} connection started ${directionText}`; + case TrafficEventType.BLOCKED: + return `${name} connection blocked ${directionText}`; + case TrafficEventType.STOPPED: + return `${name} connection stopped ${directionText}`; + default: + return "Unknown"; + } +}; + +export const TrafficEventsTableColumns: ColumnDef[] = [ + { + id: "timestamp", + header: ({ column }) => ( + Time + ), + cell: ({ row }) => ( + + ), + accessorFn: (row) => row.events?.[0]?.timestamp ?? null, + filterFn: "dateRange", + enableGlobalFilter: false, + }, + { + id: "type", + accessorKey: "type", + accessorFn: (t) => { + const isP2P = + t.source.id === t.reporter_id || t.destination.id === t.reporter_id; + return getTrafficEventTypeText(t.events[0].type, isP2P, t.direction); + }, + filterFn: "arrIncludesSomeExact", + header: ({ column }) => ( + Event + ), + cell: ({ row }) => row.getValue("type"), + }, + { + id: "text", + accessorKey: "type", + accessorFn: (t) => { + const isP2P = + t.source.id === t.reporter_id || t.destination.id === t.reporter_id; + return getTrafficEventTypeText(t.events[0].type, isP2P, t.direction); + }, + filterFn: "arrIncludesSomeExact", + header: ({ column }) => ( + Event + ), + cell: ({ row }) => , + enableGlobalFilter: false, + }, + { + id: "source", + accessorFn: (row) => row.source.address, + header: ({ column }) => ( + Source + ), + cell: ({ row }) => ( + + ), + }, + { + id: "protocol", + accessorFn: (row) => row.protocol, + filterFn: "arrIncludesSomeExact", + header: ({ column }) => ( + Protocol & Port + ), + cell: ({ row }) => , + }, + { + id: "source_port", + accessorFn: (row) => { + return parseAddressPort(row?.source.address).port; + }, + filterFn: "arrIncludesSomeExact", + }, + { + id: "destination_port", + accessorFn: (row) => { + return parseAddressPort(row?.destination.address).port; + }, + filterFn: "arrIncludesSomeExact", + }, + { + id: "destination", + accessorFn: (row) => row.destination.address, + filterFn: "arrIncludesSomeExact", + header: ({ column }) => ( + Destination + ), + cell: ({ row }) => { + return ; + }, + }, + + { + id: "bytes_all", + accessorKey: "tx_bytes", + filterFn: "arrIncludesSomeExact", + header: ({ column }) => ( + Traffic + ), + cell: ({ row }) => { + return ; + }, + enableGlobalFilter: false, + }, + { + id: "bytes_inbound", + accessorKey: "tx_bytes", + filterFn: "arrIncludesSomeExact", + header: ({ column }) => ( + Traffic + ), + cell: ({ row }) => { + return ( +
+ +
+ ); + }, + enableGlobalFilter: false, + }, + { + id: "bytes_outbound", + accessorKey: "tx_bytes", + filterFn: "arrIncludesSomeExact", + header: ({ column }) => ( + Traffic + ), + cell: ({ row }) => { + return ( +
+ +
+ ); + }, + enableGlobalFilter: false, + }, + { + id: "reporter", + accessorKey: "reporter_id", + header: ({ column }) => ( + Router + ), + cell: ({ row }) => , + }, + { + id: "policy", + accessorFn: (row) => row.policy?.name, + }, + { + id: "user", + accessorKey: "user_email", + }, +]; + +const defaultFromDate = dayjs().subtract(7, "day").startOf("day").toDate(); +const defaultToDate = dayjs().endOf("day").toDate(); + +export default function TrafficEventsTable({ + events, + isLoading, + headingTarget, + isSettingEnabled, + totalRecords, + totalPages, + onPaginationChange, + pagination, + globalFilter, + onGlobalFilterChange, + onDateFilterChange, + dateFrom, + dateTo, + filters, + onFilterChange, + users, + userId, + onUserFilterChange, + onResetAllFilters, + apiUrl, + connectionTypeFilter, + onConnectionTypeFilterChange, +}: Readonly) { + useFetchApi("/peers"); + const { mutate } = useSWRConfig(); + const path = usePathname(); + const router = useRouter(); + + const [sorting, setSorting] = useLocalStorage( + "netbird-table-sort" + path, + [ + { + id: "timestamp", + desc: true, + }, + ], + ); + + const userSelectOptions = useMemo(() => { + if (!users) return []; + return users.map((user) => { + return { + id: user.id || "", + name: user.name || "", + email: user.email || "NetBird", + } as UserSelectOption; + }); + }, [users]); + + const [dateRange, setDateRange] = useState(() => { + if (dateFrom || dateTo) { + return { + from: dateFrom ? dayjs(dateFrom).toDate() : undefined, + to: dateTo ? dayjs(dateTo).toDate() : undefined, + }; + } + return { + from: defaultFromDate, + to: defaultToDate, + }; + }); + + const handleDateFilterChange = useCallback( + (range?: DateRange) => { + setDateRange(range); + if (range && onDateFilterChange) { + onDateFilterChange(range.from, range.to); + } else if (onDateFilterChange) { + onDateFilterChange(undefined, undefined); + } + }, + [onDateFilterChange], + ); + + const handleSearchChange = useCallback( + (value: string) => { + if (onGlobalFilterChange) { + onGlobalFilterChange(value); + } + }, + [onGlobalFilterChange], + ); + + const handleFilterChange = useCallback( + (newFilters: { + type?: string[]; + connection_type?: string; + direction?: string[]; + protocol?: string[]; + }) => { + if (onFilterChange) { + onFilterChange(newFilters); + } + }, + [onFilterChange], + ); + + const handleUserFilterChange = useCallback( + (selectedUserEmail: string | undefined) => { + if (onUserFilterChange) { + const selectedUser = userSelectOptions.find( + (user) => user.email === selectedUserEmail, + ); + onUserFilterChange(selectedUser?.id || ""); + } + }, + [onUserFilterChange, userSelectOptions], + ); + + const handleResetAllFilters = useCallback(() => { + setDateRange({ + from: defaultFromDate, + to: defaultToDate, + }); + onResetAllFilters?.(); + }, [onResetAllFilters]); + + const hasFiltersApplied = useMemo(() => { + return !!( + (globalFilter && globalFilter.trim() !== "") || + (filters?.type && filters.type.length > 0) || + (filters?.connection_type && filters.connection_type.length > 0) || + (filters?.direction && filters.direction.length > 0) || + (filters?.protocol && filters.protocol.length > 0) || + userId || + (dateFrom && dateFrom !== dayjs(defaultFromDate).format("YYYY-MM-DD")) || + (dateTo && dateTo !== dayjs(defaultToDate).format("YYYY-MM-DD")) || + connectionTypeFilter !== "" + ); + }, [globalFilter, filters, userId, dateFrom, dateTo, connectionTypeFilter]); + + const renderNoResults = () => { + if (!isSettingEnabled) { + return ( + + } + color={"gray"} + size={"large"} + /> + } + title={"Traffic Events"} + description={ + "Traffic Events help you understand the network activity in your organization. " + + "You can see which machines are connecting to each other, and what kind of traffic is flowing between them." + } + button={ + + } + learnMore={ + <> + Learn more about + + Traffic Events + + + + } + /> + ); + } + + return ( + + ); + }; + + return ( + { + if (e.events.length < 2) return undefined; + return ; + }} + columns={TrafficEventsTableColumns} + columnVisibility={{ + user: false, + search: false, + source_port: false, + destination_port: false, + bytes_inbound: false, + bytes_outbound: false, + type: false, + timestamp: false, + policy: false, + }} + data={events} + searchPlaceholder={"Search by ip, port, peer or resource..."} + onFilterReset={handleResetAllFilters} + showResetFilterButton={false} + manualPagination={true} + manualFiltering={true} + keepStateInLocalStorage={false} + pageCount={totalPages} + pagination={pagination} + onPaginationChange={onPaginationChange} + totalRecords={totalRecords} + globalFilter={globalFilter} + onGlobalFilterChange={handleSearchChange} + getStartedCard={renderNoResults()} + > + {(table) => { + return ( + <> + + + { + handleDateFilterChange(range); + }} + /> + + user.id === userId)?.email || + "" + } + onChange={handleUserFilterChange} + /> + + + + + + { + mutate(apiUrl ?? "/events/network-traffic").then(); + }} + /> + + + + ); + }} + + ); +} diff --git a/src/cloud/traffic-events/interfaces/TrafficEvent.ts b/src/cloud/traffic-events/interfaces/TrafficEvent.ts new file mode 100644 index 0000000..ffb7230 --- /dev/null +++ b/src/cloud/traffic-events/interfaces/TrafficEvent.ts @@ -0,0 +1,81 @@ +import { + ICMPCode, + ICMPType, +} from "@/cloud/traffic-events/interfaces/TrafficEventICMP"; +import { TrafficEventProtocol } from "@/cloud/traffic-events/interfaces/TrafficEventProtocol"; + +export interface TrafficEvent { + id: string; // removed? + flow_id: string; + reporter_id: string; + source: TrafficEventMachine; + destination: TrafficEventMachine; + user: { + id: string; + email: string; + name: string; + }; + policy: { + id: string; + name: string; + }; + icmp: { + code: ICMPCode; + type: ICMPType; + }; + protocol: keyof typeof TrafficEventProtocol; + direction: TrafficEventDirection; + rx_bytes: number; + rx_packets: number; + tx_bytes: number; + tx_packets: number; + events: { + type: TrafficEventType; + timestamp: string; + }[]; +} + +export interface TrafficEventMachine { + id: string; + name: string; + os: string; + type: TrafficEventMachineType; + address: string; + dns_label: string; + geo_location: { + city_name: string; + country_code: string; + }; +} + +export enum TrafficEventDirection { + UNKNOWN = "DIRECTION_UNKNOWN", + INGRESS = "INGRESS", + EGRESS = "EGRESS", +} + +export const getTrafficEventProtocol = ( + protocol: keyof typeof TrafficEventProtocol, +): string => { + try { + return TrafficEventProtocol[protocol] ?? "Unassigned"; + } catch (error) { + return "Unknown"; + } +}; + +export enum TrafficEventType { + UNKNOWN = "TYPE_UNKNOWN", + CONNECTED = "TYPE_START", + STOPPED = "TYPE_END", + BLOCKED = "TYPE_DROP", +} + +export enum TrafficEventMachineType { + UNKNOWN = "UNKNOWN", + PEER = "PEER", + HOST_RESOURCE = "HOST_RESOURCE", + SUBNET_RESOURCE = "SUBNET_RESOURCE", + DOMAIN_RESOURCE = "DOMAIN_RESOURCE", + ROUTE = "ROUTE", +} diff --git a/src/cloud/traffic-events/interfaces/TrafficEventICMP.ts b/src/cloud/traffic-events/interfaces/TrafficEventICMP.ts new file mode 100644 index 0000000..2dd219a --- /dev/null +++ b/src/cloud/traffic-events/interfaces/TrafficEventICMP.ts @@ -0,0 +1,344 @@ +export type ICMPType = keyof typeof ICMPTypes; +export type ICMPCode = + keyof (typeof ICMPTypes)[T]["codes"]; + +// ICMP Types and their associated Codes +export const ICMPTypes = { + // Type 0 - Echo Reply + 0: { + name: "Echo Reply", + codes: { + 0: "No Code", + }, + }, + + // Type 1 - Unassigned (Reserved) + 1: { + name: "Unassigned", + codes: {}, + }, + + // Type 2 - Unassigned (Reserved) + 2: { + name: "Unassigned", + codes: {}, + }, + + // Type 3 - Destination Unreachable + 3: { + name: "Unreachable", + codes: { + 0: "Net Unreachable", + 1: "Host Unreachable", + 2: "Protocol Unreachable", + 3: "Port Unreachable", + 4: "Fragmentation Needed and Don't Fragment was Set", + 5: "Source Route Failed", + 6: "Destination Network Unknown", + 7: "Destination Host Unknown", + 8: "Source Host Isolated", + 9: "Communication with Destination Network is Administratively Prohibited", + 10: "Communication with Destination Host is Administratively Prohibited", + 11: "Destination Network Unreachable for Type of Service", + 12: "Destination Host Unreachable for Type of Service", + 13: "Communication Administratively Prohibited", + 14: "Host Precedence Violation", + 15: "Precedence cutoff in effect", + }, + }, + + // Type 4 - Source Quench + 4: { + name: "Source Quench", + codes: { + 0: "No Code", + }, + }, + + // Type 5 - Redirect + 5: { + name: "Redirect", + codes: { + 0: "Redirect Datagram for the Network (or subnet)", + 1: "Redirect Datagram for the Host", + 2: "Redirect Datagram for the Type of Service and Network", + 3: "Redirect Datagram for the Type of Service and Host", + }, + }, + + // Type 6 - Alternate Host Address (Deprecated) + 6: { + name: "Alternate Host Address", + codes: { + 0: "Alternate Address for Host", + }, + }, + + // Type 7 - Unassigned + 7: { + name: "Unassigned", + codes: {}, + }, + + // Type 8 - Echo + 8: { + name: "Echo", + codes: { + 0: "No Code", + }, + }, + + // Type 9 - Router Advertisement + 9: { + name: "Router Advertisement", + codes: { + 0: "Normal router advertisement", + 16: "Does not route common traffic", + }, + }, + + // Type 10 - Router Solicitation + 10: { + name: "Router Solicitation", + codes: { + 0: "No Code", + }, + }, + + // Type 11 - Time Exceeded + 11: { + name: "Time Exceeded", + codes: { + 0: "Time to Live exceeded in Transit", + 1: "Fragment Reassembly Time Exceeded", + }, + }, + + // Type 12 - Parameter Problem + 12: { + name: "Parameter Problem", + codes: { + 0: "Pointer indicates the error", + 1: "Missing a Required Option", + 2: "Bad Length", + }, + }, + + // Type 13 - Timestamp + 13: { + name: "Timestamp", + codes: { + 0: "No Code", + }, + }, + + // Type 14 - Timestamp Reply + 14: { + name: "Timestamp Reply", + codes: { + 0: "No Code", + }, + }, + + // Type 15 - Information Request (Deprecated) + 15: { + name: "Information Request", + codes: { + 0: "No Code", + }, + }, + + // Type 16 - Information Reply (Deprecated) + 16: { + name: "Information Reply", + codes: { + 0: "No Code", + }, + }, + + // Type 17 - Address Mask Request (Deprecated) + 17: { + name: "Address Mask Request", + codes: { + 0: "No Code", + }, + }, + + // Type 18 - Address Mask Reply (Deprecated) + 18: { + name: "Address Mask Reply", + codes: { + 0: "No Code", + }, + }, + + // Type 19 - Reserved + 19: { + name: "Reserved (for Security)", + codes: {}, + }, + + // Types 20-29 - Reserved + ...Object.fromEntries( + Array.from({ length: 10 }, (_, i) => i + 20).map((i) => [ + i, + { + name: "Reserved", + codes: {}, + }, + ]), + ), + + // Type 30 - Traceroute (Deprecated) + 30: { + name: "Traceroute", + codes: { + 0: "No Code", + }, + }, + + // Type 31 - Datagram Conversion Error (Deprecated) + 31: { + name: "Datagram Conversion Error", + codes: { + 0: "No Code", + }, + }, + + // Type 32 - Mobile Host Redirect (Deprecated) + 32: { + name: "Mobile Host Redirect", + codes: { + 0: "No Code", + }, + }, + + // Type 33 - IPv6 Where-Are-You (Deprecated) + 33: { + name: "IPv6 Where-Are-You", + codes: { + 0: "No Code", + }, + }, + + // Type 34 - IPv6 I-Am-Here (Deprecated) + 34: { + name: "IPv6 I-Am-Here", + codes: { + 0: "No Code", + }, + }, + + // Type 35 - Mobile Registration Request (Deprecated) + 35: { + name: "Mobile Registration Request", + codes: { + 0: "No Code", + }, + }, + + // Type 36 - Mobile Registration Reply (Deprecated) + 36: { + name: "Mobile Registration Reply", + codes: { + 0: "No Code", + }, + }, + + // Type 37 - Domain Name Request (Deprecated) + 37: { + name: "Domain Name Request", + codes: { + 0: "No Code", + }, + }, + + // Type 38 - Domain Name Reply (Deprecated) + 38: { + name: "Domain Name Reply", + codes: { + 0: "No Code", + }, + }, + + // Type 39 - SKIP (Deprecated) + 39: { + name: "SKIP", + codes: { + 0: "No Code", + }, + }, + + // Type 40 - Photuris + 40: { + name: "Photuris", + codes: { + 0: "Reserved", + 1: "Unknown security index", + 2: "Valid security index, but invalid SPI", + 3: "Valid security index and SPI, but authentication failed", + 4: "Valid security index and SPI, but decryption failed", + }, + }, + + // Type 41 - ICMP messages utilized by experimental mobility protocols + 41: { + name: "ICMP messages utilized by experimental mobility protocols", + codes: { + 0: "No Code", + }, + }, + + // Types 42-252 - Unassigned + ...Object.fromEntries( + Array.from({ length: 211 }, (_, i) => i + 42).map((i) => [ + i, + { + name: "Unassigned", + codes: {}, + }, + ]), + ), + + // Type 253 - RFC3692-style Experiment 1 + 253: { + name: "RFC3692-style Experiment 1", + codes: { + 0: "No Code", + }, + }, + + // Type 254 - RFC3692-style Experiment 2 + 254: { + name: "RFC3692-style Experiment 2", + codes: { + 0: "No Code", + }, + }, + + // Type 255 - Reserved + 255: { + name: "Reserved", + codes: {}, + }, +} as const; + +export function getICMPTypeName(type: ICMPType): string { + try { + return ICMPTypes[type].name; + } catch (e) { + return "Unknown Type"; + } +} + +export function getICMPCodeDescription(type: ICMPType, code: number): string { + try { + return ( + ICMPTypes[type].codes[ + code as keyof (typeof ICMPTypes)[typeof type]["codes"] + ] || "Unknown Code" + ); + } catch (e) { + return "Unknown Code"; + } +} diff --git a/src/cloud/traffic-events/interfaces/TrafficEventICMPv6.ts b/src/cloud/traffic-events/interfaces/TrafficEventICMPv6.ts new file mode 100644 index 0000000..57f77fd --- /dev/null +++ b/src/cloud/traffic-events/interfaces/TrafficEventICMPv6.ts @@ -0,0 +1,95 @@ +export type ICMPv6Type = keyof typeof ICMPv6Types; + +export const ICMPv6Types = { + 1: { + name: "Destination Unreachable", + codes: { + 0: "No route to destination", + 1: "Administratively prohibited", + 2: "Beyond scope of source address", + 3: "Address unreachable", + 4: "Port unreachable", + 5: "Source address failed ingress/egress policy", + 6: "Reject route to destination", + }, + }, + 2: { + name: "Packet Too Big", + codes: { + 0: "No Code", + }, + }, + 3: { + name: "Time Exceeded", + codes: { + 0: "Hop limit exceeded in transit", + 1: "Fragment reassembly time exceeded", + }, + }, + 4: { + name: "Parameter Problem", + codes: { + 0: "Erroneous header field encountered", + 1: "Unrecognized Next Header type encountered", + 2: "Unrecognized IPv6 option encountered", + }, + }, + 128: { + name: "Echo Request", + codes: { + 0: "No Code", + }, + }, + 129: { + name: "Echo Reply", + codes: { + 0: "No Code", + }, + }, + 130: { + name: "Multicast Listener Query", + codes: { 0: "No Code" }, + }, + 131: { + name: "Multicast Listener Report", + codes: { 0: "No Code" }, + }, + 132: { + name: "Multicast Listener Done", + codes: { 0: "No Code" }, + }, + 133: { + name: "Router Solicitation", + codes: { 0: "No Code" }, + }, + 134: { + name: "Router Advertisement", + codes: { 0: "No Code" }, + }, + 135: { + name: "Neighbor Solicitation", + codes: { 0: "No Code" }, + }, + 136: { + name: "Neighbor Advertisement", + codes: { 0: "No Code" }, + }, + 137: { + name: "Redirect Message", + codes: { 0: "No Code" }, + }, +} as const; + +export function getICMPv6TypeName(type: number): string { + const entry = ICMPv6Types[type as ICMPv6Type]; + if (!entry) return `Type ${type}`; + return entry.name; +} + +export function getICMPv6CodeDescription(type: number, code: number): string { + const entry = ICMPv6Types[type as ICMPv6Type]; + if (!entry) return `Code ${code}`; + const codeName = + entry.codes[code as keyof (typeof ICMPv6Types)[ICMPv6Type]["codes"]]; + return codeName || `Code ${code}`; +} diff --git a/src/cloud/traffic-events/interfaces/TrafficEventProtocol.ts b/src/cloud/traffic-events/interfaces/TrafficEventProtocol.ts new file mode 100644 index 0000000..10a439c --- /dev/null +++ b/src/cloud/traffic-events/interfaces/TrafficEventProtocol.ts @@ -0,0 +1,154 @@ +export const TrafficEventProtocol = { + 0: "HOPOPT", + 1: "ICMP", + 2: "IGMP", + 3: "GGP", + 4: "IPv4", + 5: "ST", + 6: "TCP", + 7: "CBT", + 8: "EGP", + 9: "IGP", + 10: "BBN-RCC-MON", + 11: "NVP-II", + 12: "PUP", + 13: "ARGUS", + 14: "EMCON", + 15: "XNET", + 16: "CHAOS", + 17: "UDP", + 18: "MUX", + 19: "DCN-MEAS", + 20: "HMP", + 21: "PRM", + 22: "XNS-IDP", + 23: "TRUNK-1", + 24: "TRUNK-2", + 25: "LEAF-1", + 26: "LEAF-2", + 27: "RDP", + 28: "IRTP", + 29: "ISO-TP4", + 30: "NETBLT", + 31: "MFE-NSP", + 32: "MERIT-INP", + 33: "DCCP", + 34: "3PC", + 35: "IDPR", + 36: "XTP", + 37: "DDP", + 38: "IDPR-CMTP", + 39: "TP++", + 40: "IL", + 41: "IPv6", + 42: "SDRP", + 43: "IPv6-Route", + 44: "IPv6-Frag", + 45: "IDRP", + 46: "RSVP", + 47: "GRE", + 48: "DSR", + 49: "BNA", + 50: "ESP", + 51: "AH", + 52: "I-NLSP", + 53: "SWIPE", + 54: "NARP", + 55: "Min-IPv4", + 56: "TLSP", + 57: "SKIP", + 58: "ICMP6", + 59: "IPv6-NoNxt", + 60: "IPv6-Opts", + 61: "Any host internal", + 62: "CFTP", + 63: "Any local network", + 64: "SAT-EXPAK", + 65: "KRYPTOLAN", + 66: "RVD", + 67: "IPPC", + 68: "Any distributed fs", + 69: "SAT-MON", + 70: "VISA", + 71: "IPCV", + 72: "CPNX", + 73: "CPHB", + 74: "WSN", + 75: "PVP", + 76: "BR-SAT-MON", + 77: "SUN-ND", + 78: "WB-MON", + 79: "WB-EXPAK", + 80: "ISO-IP", + 81: "VMTP", + 82: "SECURE-VMTP", + 83: "VINES", + 84: "IPTM", + 85: "NSFNET-IGP", + 86: "DGP", + 87: "TCF", + 88: "EIGRP", + 89: "OSPFIGP", + 90: "Sprite-RPC", + 91: "LARP", + 92: "MTP", + 93: "AX.25", + 94: "IPIP", + 95: "MICP", + 96: "SCC-SP", + 97: "ETHERIP", + 98: "ENCAP", + 99: "Any private encryption", + 100: "GMTP", + 101: "IFMP", + 102: "PNNI", + 103: "PIM", + 104: "ARIS", + 105: "SCPS", + 106: "QNX", + 107: "A/N", + 108: "IPComp", + 109: "SNP", + 110: "Compaq-Peer", + 111: "IPX-in-IP", + 112: "VRRP", + 113: "PGM", + 114: "Any 0 hop", + 115: "L2TP", + 116: "DDX", + 117: "IATP", + 118: "STP", + 119: "SRP", + 120: "UTI", + 121: "SMP", + 122: "SM", + 123: "PTP", + 124: "ISIS-over-IPv4", + 125: "FIRE", + 126: "CRTP", + 127: "CRUDP", + 128: "SSCOPMCE", + 129: "IPLT", + 130: "SPS", + 131: "PIPE", + 132: "SCTP", + 133: "FC", + 134: "RSVP-E2E-IGNORE", + 135: "Mobility-Header", + 136: "UDPLite", + 137: "MPLS-in-IP", + 138: "MANET", + 139: "HIP", + 140: "Shim6", + 141: "WESP", + 142: "ROHC", + 143: "Ethernet", + 144: "AGGFRAG", + 145: "NSH", + 146: "Homa", + 147: "BIT-EMU", + // ... (148-252 unassigned) + 253: "Experimental", + 254: "Experimental", + 255: "Reserved", +} as const; diff --git a/src/cloud/traffic-events/misc/TrafficEventChart.tsx b/src/cloud/traffic-events/misc/TrafficEventChart.tsx new file mode 100644 index 0000000..57f03ab --- /dev/null +++ b/src/cloud/traffic-events/misc/TrafficEventChart.tsx @@ -0,0 +1,141 @@ +import { faker } from "@faker-js/faker/locale/de"; +import { cn } from "@utils/helpers"; +import { + CategoryScale, + Chart as ChartJS, + ChartData, + ChartOptions, + Filler, + Legend, + LinearScale, + LineElement, + PointElement, + Title, + Tooltip, +} from "chart.js"; +import * as React from "react"; +import { useState } from "react"; +import { Line } from "react-chartjs-2"; + +type Props = {}; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Filler, + Legend, +); + +export const options: ChartOptions<"line"> = { + responsive: true, + elements: { + line: { + tension: 0.4, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + }, + }, + scales: { + y: { + grid: { + color: "rgba(255,255,255,0.05)", + }, + ticks: { + display: false, + color: "rgba(255,255,255,1)", + count: 3, + }, + }, + x: { + grid: { + color: "rgba(255,255,255,0.05)", + }, + ticks: { + display: false, + color: "rgba(255,255,255,0.5)", + }, + }, + }, +}; + +const labels = [ + "0:00", + "0:05", + "0:10", + "0:15", + "0:20", + "0:25", + "0:30", + "0:35", + "0:40", + "0:45", + "0:50", + "0:55", + "1:00", +]; + +export const TrafficEventChart = ({}: Props) => { + const chartRef = React.useRef>(null); + const [gradientColor, setGradientColor] = useState(); + + React.useEffect(() => { + const chart = chartRef.current; + + if (!chart) { + return; + } + + const ctx = chart.ctx; + const gradient = ctx.createLinearGradient(0, 0, 0, 150); + gradient.addColorStop(0, "rgba(246,131,48,0.5)"); // Orange at the top + gradient.addColorStop(1, "rgba(246,131,48,0)"); // Transparent at the bottom + + setGradientColor(gradient); + }, []); + + const data: ChartData<"line"> = { + labels, + datasets: [ + { + fill: true, + data: labels.map(() => + faker.number.int({ + min: 40, + max: 100, + }), + ), + borderColor: "rgba(246,131,48,0.8)", + pointBackgroundColor: "rgba(246,131,48,1)", + backgroundColor: gradientColor, // This will be replaced by the gradient + }, + ], + }; + + return ( +
+
+ +
+
+ ); +}; diff --git a/src/cloud/traffic-events/misc/TrafficEventDetails.tsx b/src/cloud/traffic-events/misc/TrafficEventDetails.tsx new file mode 100644 index 0000000..a16ea84 --- /dev/null +++ b/src/cloud/traffic-events/misc/TrafficEventDetails.tsx @@ -0,0 +1,155 @@ +import { Modal, SidebarModalContent } from "@components/modal/Modal"; +import { cn } from "@utils/helpers"; +import { ArrowDownIcon, ArrowUpIcon, CheckIcon, HashIcon } from "lucide-react"; +import * as React from "react"; +import { TrafficEventChart } from "@/cloud/traffic-events/misc/TrafficEventChart"; + +type Props = { + open: boolean; + setOpen: (open: boolean) => void; +}; + +export const TrafficEventDetails = ({ open, setOpen }: Props) => { + return ( + + +
+
+

+ + NBSE2911929 +

+
+ +
+
    + + Connection successfully established between Peer 1 and Peer 2 + + Peer 2 accepted the connection request + + Posture Check XYZ passed + Access Control Policy XYZ passed + + Peer 1 requested to connect to Peer 2 + +
+
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+
+ ); +}; + +const ListItem = ({ + children, + hideLastLine = false, +}: { + children: React.ReactNode; + hideLastLine?: boolean; +}) => { + return ( +
  • +
    + +
    +
    + + Th, 27 February 2025, 16:08 + + {children} +
    +
  • + ); +}; + +const Separator = () => { + return
    ; +}; + +const ExpandedCards = () => { + return ( +
    +
    + + Duration + + + {"< "} + 10 minutes + +
    +
    + + Inbound Traffic + + + + 15.2 GB + +
    +
    + + Outbound Traffic + + + + 10.8 GB + +
    +
    + ); +}; diff --git a/src/cloud/traffic-events/table/TrafficEventDescription.tsx b/src/cloud/traffic-events/table/TrafficEventDescription.tsx new file mode 100644 index 0000000..dc93c0a --- /dev/null +++ b/src/cloud/traffic-events/table/TrafficEventDescription.tsx @@ -0,0 +1,302 @@ +import { cn } from "@utils/helpers"; +import dayjs from "dayjs"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import * as React from "react"; +import { useMemo } from "react"; +import { + TrafficEvent, + TrafficEventDirection, + TrafficEventMachine, + TrafficEventMachineType, + TrafficEventType, +} from "@/cloud/traffic-events/interfaces/TrafficEvent"; +import { getTrafficEventTypeText } from "@/cloud/traffic-events/TrafficEventsTable"; +import { stripZeroPort } from "@/cloud/traffic-events/utils/parseAddress"; +import { usePeers } from "@/contexts/PeersProvider"; + +type Props = { + event: TrafficEvent; + type: TrafficEventType; + showCaret?: boolean; +}; + +const isResource = (m: TrafficEventMachine) => { + return m.type !== TrafficEventMachineType.PEER; +}; + +const getNamePrefix = (m: TrafficEventMachine) => { + switch (m.type) { + case TrafficEventMachineType.PEER: + return "Peer "; + case TrafficEventMachineType.ROUTE: + return "Route "; + case TrafficEventMachineType.UNKNOWN: + return ""; + default: + return "Resource "; + } +}; + +export const TrafficEventDescription = ({ + event, + type, + showCaret = false, +}: Props) => { + const { peers } = usePeers(); + + const routerName = useMemo(() => { + const reporter = peers?.find((peer) => peer.id === event.reporter_id); + return reporter ? {reporter.name} : Unknown; + }, [event.reporter_id, peers]); + + const timestamp = event.events?.find((e) => e.type === type)?.timestamp; + + const info = useMemo(() => { + const isP2P = + event.source.id === event.reporter_id || + event.destination.id === event.reporter_id; + const isInbound = event.direction === TrafficEventDirection.INGRESS; + const isOutbound = event.direction === TrafficEventDirection.EGRESS; + const isStarted = type === TrafficEventType.CONNECTED; + const isStopped = type === TrafficEventType.STOPPED; + const isBlocked = type === TrafficEventType.BLOCKED; + const isDestinationAResource = isResource(event.destination); + const sourceAddress = stripZeroPort(event.source.address); + const destinationAddress = stripZeroPort(event.destination.address); + const sourceName = ( + <> + {getNamePrefix(event.source)} + {event.source.name || sourceAddress} + + ); + const destinationName = ( + <> + {getNamePrefix(event.destination)} + {event.destination.name || destinationAddress} + + ); + + return { + isP2P, + isInbound, + isOutbound, + isStarted, + isStopped, + isBlocked, + isDestinationAResource, + sourceName, + destinationName, + type, + }; + }, [event]); + + const getMessage = () => { + /** + * Connection between a peer and a resource + */ + if ( + info.isP2P && + info.isOutbound && + info.isStarted && + info.isDestinationAResource + ) { + return ( + <> + {info.sourceName} requested connection to {info.destinationName} + + ); + } + + if ( + info.isP2P && + info.isOutbound && + info.isStopped && + info.isDestinationAResource + ) { + return ( + <> + {info.sourceName} stopped connection to {info.destinationName} + + ); + } + + /** + * With routing peers + */ + if ( + !info.isP2P && + info.isInbound && + info.isStarted && + info.isDestinationAResource + ) { + return ( + <> + Routing peer {routerName} received connection to{" "} + {info.destinationName} from {info.sourceName} + + ); + } + + if ( + !info.isP2P && + info.isOutbound && + info.isStarted && + info.isDestinationAResource + ) { + return ( + <> + Routing peer {routerName} started routing to {info.destinationName}{" "} + from {info.sourceName} + + ); + } + + if ( + !info.isP2P && + info.isOutbound && + info.isStopped && + info.isDestinationAResource + ) { + return ( + <> + Routing peer {routerName} stopped routing to {info.destinationName}{" "} + from {info.sourceName} + + ); + } + + if ( + !info.isP2P && + info.isInbound && + info.isStopped && + info.isDestinationAResource + ) { + return ( + <> + Routing peer {routerName} stopped connection to {info.destinationName}{" "} + from {info.sourceName} + + ); + } + + if ( + !info.isP2P && + info.isDestinationAResource && + info.isBlocked && + info.isOutbound + ) { + return <>Connection to {info.destinationName} was blocked; + } + + if ( + !info.isP2P && + info.isDestinationAResource && + info.isBlocked && + info.isInbound + ) { + return ( + <> + Routing peer {routerName} blocked connection to {info.destinationName} + + ); + } + + /** + * P2P connection between two peers + */ + if (info.isP2P && info.isOutbound && info.isStarted) { + return ( + <> + {info.sourceName} requested P2P connection to {info.destinationName} + + ); + } + + if (info.isP2P && info.isInbound && info.isStarted) { + return ( + <> + {info.destinationName} received P2P connection from {info.sourceName} + + ); + } + + if (info.isP2P && info.isOutbound && info.isStopped) { + return ( + <> + {info.sourceName} stopped P2P connection to {info.destinationName} + + ); + } + + if (info.isP2P && info.isInbound && info.isStopped) { + return ( + <> + {info.destinationName} stopped P2P connection from {info.sourceName} + + ); + } + + if (info.isP2P && info.isOutbound && info.isBlocked) { + return ( + <> + {info.sourceName} blocked P2P connection to {info.destinationName} + + ); + } + + if (info.isP2P && info.isInbound && info.isBlocked) { + return ( + <> + {info.destinationName} blocked P2P connection from {info.sourceName} + + ); + } + + // Fallback to generic message + return ( + <>{getTrafficEventTypeText(info.type, info.isP2P, event.direction)} + ); + }; + + return ( +
    +
    + + {dayjs(timestamp).format("MMM D, YYYY [at] h:mm:ss A")} + +
    +
    + {getMessage()} + {showCaret && ( +
    + + +
    + )} +
    +
    + ); +}; + +const Mark = ({ children }: { children: React.ReactNode }) => { + return {children}; +}; diff --git a/src/cloud/traffic-events/table/TrafficEventsBytesCell.tsx b/src/cloud/traffic-events/table/TrafficEventsBytesCell.tsx new file mode 100644 index 0000000..b3773f6 --- /dev/null +++ b/src/cloud/traffic-events/table/TrafficEventsBytesCell.tsx @@ -0,0 +1,44 @@ +import { cn, formatBytes } from "@utils/helpers"; +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; +import * as React from "react"; +import { TrafficEvent } from "@/cloud/traffic-events/interfaces/TrafficEvent"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; + +type Props = { + event: TrafficEvent; + showInbound?: boolean; + showOutbound?: boolean; +}; + +export const TrafficEventsBytesCell = ({ + event, + showInbound = true, + showOutbound = true, +}: Props) => { + if ( + showInbound && + showOutbound && + event.rx_bytes === 0 && + event.tx_bytes === 0 + ) + return ; + if (showInbound && event.rx_bytes === 0 && !showOutbound) return ; + if (showOutbound && event.tx_bytes === 0 && !showInbound) return ; + + return ( +
    + {showInbound && ( +
    + + {formatBytes(event.rx_bytes)} +
    + )} + {showOutbound && ( +
    + + {formatBytes(event.tx_bytes)} +
    + )} +
    + ); +}; diff --git a/src/cloud/traffic-events/table/TrafficEventsDetailRow.tsx b/src/cloud/traffic-events/table/TrafficEventsDetailRow.tsx new file mode 100644 index 0000000..90b4940 --- /dev/null +++ b/src/cloud/traffic-events/table/TrafficEventsDetailRow.tsx @@ -0,0 +1,155 @@ +import { cn } from "@utils/helpers"; +import { ArrowUpRightIcon } from "lucide-react"; +import Link from "next/link"; +import * as React from "react"; +import { + TrafficEvent, + TrafficEventType, +} from "@/cloud/traffic-events/interfaces/TrafficEvent"; +import { TrafficEventDescription } from "@/cloud/traffic-events/table/TrafficEventDescription"; + +type Props = { + event: TrafficEvent; + className?: string; +}; + +export const TrafficEventsDetailRow = ({ event, className }: Props) => { + const otherEvents = event.events.filter( + (e) => e.type !== TrafficEventType.CONNECTED, + ); + const { policy } = event; + const hasPolicy = !!policy?.id; + const isBlocked = otherEvents.some( + (e) => e.type === TrafficEventType.BLOCKED, + ); + + return ( +
    +
      + {hasPolicy && } + + {otherEvents.map((e, index) => { + const isLast = index === otherEvents.length - 1; + const isFirst = index === 0; + return ( + + ); + })} +
    +
    + ); +}; + +type ListItemProps = { + event?: TrafficEvent; + type?: TrafficEventType; + bottomLine?: boolean; + topLine?: boolean; + children?: React.ReactNode; + topLineClassName?: string; +}; + +const ListItem = ({ + event, + type, + bottomLine, + topLine, + children, + topLineClassName, +}: ListItemProps) => { + return ( +
  • + {/* Top Line */} + {topLine && ( +
    + )} + + {/* Line Between */} +
    + + {/* Circle */} +
    + + {event && type && ( + + )} + + {children && ( +
    + {children} +
    + )} +
  • + ); +}; + +const PolicyListItem = ({ + policy, + isBlocked = false, +}: { + policy?: { id: string; name: string }; + isBlocked: boolean; +}) => { + return ( + + + Policy + + + {policy?.name} + + + + {isBlocked ? "blocked" : "allowed"} the connection + + + ); +}; diff --git a/src/cloud/traffic-events/table/TrafficEventsDirectionCell.tsx b/src/cloud/traffic-events/table/TrafficEventsDirectionCell.tsx new file mode 100644 index 0000000..f817369 --- /dev/null +++ b/src/cloud/traffic-events/table/TrafficEventsDirectionCell.tsx @@ -0,0 +1,25 @@ +import Badge from "@components/Badge"; +import * as React from "react"; +import { + TrafficEvent, + TrafficEventDirection, +} from "@/cloud/traffic-events/interfaces/TrafficEvent"; + +type Props = { + event: TrafficEvent; +}; + +export const TrafficEventsDirectionCell = ({ event }: Props) => { + const direction = event.direction; + const isInbound = direction === TrafficEventDirection.INGRESS; + + return direction === TrafficEventDirection.UNKNOWN ? ( + + Unknown + + ) : ( + + {isInbound ? "Inbound" : "Outbound"} + + ); +}; diff --git a/src/cloud/traffic-events/table/TrafficEventsMachineCell.tsx b/src/cloud/traffic-events/table/TrafficEventsMachineCell.tsx new file mode 100644 index 0000000..76f7bdb --- /dev/null +++ b/src/cloud/traffic-events/table/TrafficEventsMachineCell.tsx @@ -0,0 +1,350 @@ +import CopyToClipboardText from "@components/CopyToClipboardText"; +import FullTooltip from "@components/FullTooltip"; +import TextWithTooltip from "@components/ui/TextWithTooltip"; +import { getOperatingSystem } from "@hooks/useOperatingSystem"; +import { IconDirectionSign } from "@tabler/icons-react"; +import { cn } from "@utils/helpers"; +import { + FlagIcon, + GlobeIcon, + MailIcon, + MapPin, + NetworkIcon, + RouteIcon, + UserIcon, + WorkflowIcon, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import Skeleton from "react-loading-skeleton"; +import RoundedFlag from "@/assets/countries/RoundedFlag"; +import { + TrafficEvent, + TrafficEventDirection, + TrafficEventMachine, + TrafficEventMachineType, +} from "@/cloud/traffic-events/interfaces/TrafficEvent"; +import { stripZeroPort } from "@/cloud/traffic-events/utils/parseAddress"; +import { useCountries } from "@/contexts/CountryProvider"; +import { OperatingSystem } from "@/interfaces/OperatingSystem"; +import { OSLogo } from "@/modules/peers/PeerOSCell"; + +type Props = { + event: TrafficEvent; + isSource?: boolean; +}; +export const TrafficEventsMachineCell = ({ + event, + isSource = false, +}: Props) => { + const router = useRouter(); + const machine = isSource ? event.source : event.destination; + const isPeer = machine.type === TrafficEventMachineType.PEER; + const { isLoading: isGeoDataLoading, getRegionText } = useCountries(); + + const redirectToPeer = () => { + if (!isPeer) return; + if (!machine.id) return; + router.push(`/peer?id=${machine.id}`); + }; + + const countryText = getRegionText( + machine.geo_location.country_code, + machine.geo_location.city_name, + ); + + const hasNameAndEmail = isPeer && !!event.user.email && !!event.user.name; + const showUserOnSource = + isSource && + hasNameAndEmail && + event.direction === TrafficEventDirection.EGRESS; + const showUserOnDestination = + !isSource && + hasNameAndEmail && + event.direction === TrafficEventDirection.INGRESS; + const showUser = showUserOnSource || showUserOnDestination; + + const isExitNode = machine.name?.includes("Exit Node"); + + return ( + { + e.stopPropagation(); + e.preventDefault(); + }} + > + {machine.type !== TrafficEventMachineType.UNKNOWN && ( + + } + label={ + machine.type === TrafficEventMachineType.PEER + ? "Peer" + : isExitNode + ? "Exit Node" + : "Resource" + } + value={ + + {machine.name || "Unknown"} + + } + /> + )} + + {machine.dns_label && ( + } + label={"Domain"} + value={ + + {machine.dns_label} + + } + /> + )} + + {showUser && ( + <> + } + label={"User"} + value={ + + {event?.user.name} + + } + /> + {event?.user.email && ( + } + label={"User E-Mail"} + value={ + + {event?.user.email} + + } + /> + )} + + )} + + } + label={isSource ? "Source" : "Destination"} + value={ + + {stripZeroPort(machine.address ?? "")} + + } + /> + + } + label={"Region"} + value={ + <> + {isGeoDataLoading ? ( + + ) : ( + +
    + {countryText} +
    +
    + )} + + } + /> + + } + > + +
    + ); +}; + +const ListItem = ({ + icon, + label, + value, + className, +}: { + icon: React.ReactNode; + label: string; + value: string | React.ReactNode; + className?: string; +}) => { + return ( +
    +
    + {icon} + {label} +
    +
    {value}
    +
    + ); +}; + +type MachineCardProps = { + machine: TrafficEventMachine; + onClick?: () => void; + showUser?: boolean; +}; +export const MachineCard = ({ + machine, + onClick, + showUser = false, +}: MachineCardProps) => { + const isPeer = machine.type === TrafficEventMachineType.PEER; + + return ( + + ); +}; + +const PeerOSIcon = ({ os }: { os: string }) => { + const osType = getOperatingSystem(os); + return ( +
    + +
    + ); +}; + +const ResourceIcon = ({ + type, + size = 15, + name, +}: { + type: TrafficEventMachineType; + size?: number; + name?: string; +}) => { + if (name?.includes("Exit Node")) { + return ; + } + + switch (type) { + case TrafficEventMachineType.DOMAIN_RESOURCE: + return ; + case TrafficEventMachineType.SUBNET_RESOURCE: + return ; + case TrafficEventMachineType.HOST_RESOURCE: + return ; + case TrafficEventMachineType.ROUTE: + return ; + default: + return ; + } +}; diff --git a/src/cloud/traffic-events/table/TrafficEventsPortCell.tsx b/src/cloud/traffic-events/table/TrafficEventsPortCell.tsx new file mode 100644 index 0000000..3f14ed7 --- /dev/null +++ b/src/cloud/traffic-events/table/TrafficEventsPortCell.tsx @@ -0,0 +1,123 @@ +import Badge from "@components/Badge"; +import FullTooltip from "@components/FullTooltip"; +import { cn } from "@utils/helpers"; +import { CircleHelp, HashIcon, Share2, TagIcon } from "lucide-react"; +import * as React from "react"; +import { + getTrafficEventProtocol, + TrafficEvent, +} from "@/cloud/traffic-events/interfaces/TrafficEvent"; +import { + getICMPCodeDescription, + getICMPTypeName, +} from "@/cloud/traffic-events/interfaces/TrafficEventICMP"; +import { + getICMPv6CodeDescription, + getICMPv6TypeName, +} from "@/cloud/traffic-events/interfaces/TrafficEventICMPv6"; +import { parseAddressPort } from "@/cloud/traffic-events/utils/parseAddress"; + +type Props = { + event: TrafficEvent; +}; + +export const TrafficEventsPortCell = ({ event }: Props) => { + const { port: destinationPort } = parseAddressPort( + event?.destination.address, + ); + + const isICMPv4 = event.protocol === 1; + const isICMPv6 = event.protocol === 58; + const isICMP = isICMPv4 || isICMPv6; + const ICMPType = isICMPv6 + ? getICMPv6TypeName(event.icmp.type) + : getICMPTypeName(event.icmp.type); + const ICMPCode = isICMPv6 + ? getICMPv6CodeDescription(event.icmp.type, event.icmp.code) + : getICMPCodeDescription(event.icmp.type, event.icmp.code); + const protocolName = getTrafficEventProtocol(event.protocol); + + return ( +
    + + + + {protocolName} + + {destinationPort && destinationPort !== "0" && ( + + {destinationPort} + + )} + {isICMP && ( + { + e.stopPropagation(); + e.preventDefault(); + }} + > + } + label={`Type ${event.icmp.type}`} + value={ICMPType} + /> + } + label={`Code ${event.icmp.code}`} + value={ICMPCode} + /> +
    + } + > + + {ICMPType} + + + + )} + + ); +}; + +const ListItem = ({ + icon, + label, + value, + className, +}: { + icon: React.ReactNode; + label: string; + value: string | React.ReactNode; + className?: string; +}) => { + return ( +
    +
    + {icon} + {label} +
    +
    {value}
    +
    + ); +}; diff --git a/src/cloud/traffic-events/table/TrafficEventsReporterCell.tsx b/src/cloud/traffic-events/table/TrafficEventsReporterCell.tsx new file mode 100644 index 0000000..b0043e7 --- /dev/null +++ b/src/cloud/traffic-events/table/TrafficEventsReporterCell.tsx @@ -0,0 +1,53 @@ +import { cn } from "@utils/helpers"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { TrafficEvent } from "@/cloud/traffic-events/interfaces/TrafficEvent"; +import { usePeers } from "@/contexts/PeersProvider"; +import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; + +type Props = { + event: TrafficEvent; +}; + +export const TrafficEventsReporterCell = ({ event }: Props) => { + const { peers } = usePeers(); + const router = useRouter(); + + const isP2P = + event.source.id === event.reporter_id || + event.destination.id === event.reporter_id; + + if (isP2P) + return ( +
    + +
    + ); + + const reporter = peers?.find((peer) => peer.id === event.reporter_id); + if (!reporter) + return ( +
    + +
    + ); + + return ( +
    +
    router.push("/peer?id=" + reporter.id)} + > + +
    + {event?.user.email} +
    +
    +
    +
    + ); +}; diff --git a/src/cloud/traffic-events/table/TrafficEventsTextCell.tsx b/src/cloud/traffic-events/table/TrafficEventsTextCell.tsx new file mode 100644 index 0000000..b65f85e --- /dev/null +++ b/src/cloud/traffic-events/table/TrafficEventsTextCell.tsx @@ -0,0 +1,64 @@ +import { cn } from "@utils/helpers"; +import * as React from "react"; +import { useMemo } from "react"; +import { + TrafficEvent, + TrafficEventType, +} from "@/cloud/traffic-events/interfaces/TrafficEvent"; +import { TrafficEventDescription } from "@/cloud/traffic-events/table/TrafficEventDescription"; + +type Props = { + event: TrafficEvent; +}; + +export const TrafficEventsTextCell = ({ event }: Props) => { + const trafficEvent = useMemo(() => { + const start = event.events?.find( + (e) => e.type === TrafficEventType.CONNECTED, + ); + // Fallback to the other event in case there is no CONNECTED event + const fallback = event.events?.find( + (e) => e.type !== TrafficEventType.CONNECTED, + ); + if (!start) return fallback; + return start; + }, [event]); + + const hasOtherEvents = event.events?.length > 1; + + return ( + trafficEvent && ( +
    + {hasOtherEvents && ( +
    + )} + + + 1} + /> +
    + ) + ); +}; diff --git a/src/cloud/traffic-events/table/TrafficEventsTimeCell.tsx b/src/cloud/traffic-events/table/TrafficEventsTimeCell.tsx new file mode 100644 index 0000000..f57a2c3 --- /dev/null +++ b/src/cloud/traffic-events/table/TrafficEventsTimeCell.tsx @@ -0,0 +1,45 @@ +import { cn } from "@utils/helpers"; +import dayjs from "dayjs"; +import { ClockIcon } from "lucide-react"; +import * as React from "react"; + +type Props = { + timestamp: string; + className?: string; +}; + +export const TrafficEventsTimeCell = ({ timestamp, className }: Props) => { + return ( +
    +
    +
    + + {dayjs(timestamp).format("MMM D, YYYY")} + + + {dayjs(timestamp).format("h:mm:ss A")} + +
    +
    +
    + ); +}; + +const Duration = ({ duration }: { duration: number }) => { + return ( +
    + + 13m 17s +
    + ); +}; diff --git a/src/cloud/traffic-events/utils/parseAddress.ts b/src/cloud/traffic-events/utils/parseAddress.ts new file mode 100644 index 0000000..01dc428 --- /dev/null +++ b/src/cloud/traffic-events/utils/parseAddress.ts @@ -0,0 +1,34 @@ +// parseAddressPort extracts the IP and port from an address string. +// Handles both IPv4 ("1.2.3.4:80") and IPv6 ("[::1]:80" or "::1") formats. +export function parseAddressPort(address?: string): { + ip: string; + port: string | undefined; +} { + if (!address) return { ip: "", port: undefined }; + + // IPv6 with brackets: [::1]:port + const bracketMatch = address.match(/^\[(.+)\]:(\d+)$/); + if (bracketMatch) { + return { ip: bracketMatch[1], port: bracketMatch[2] }; + } + + // IPv4: 1.2.3.4:port (exactly one colon for port separator) + const lastColon = address.lastIndexOf(":"); + if (lastColon !== -1 && address.indexOf(":") === lastColon) { + return { + ip: address.slice(0, lastColon), + port: address.slice(lastColon + 1), + }; + } + + // Bare IPv6 or address without port + return { ip: address, port: undefined }; +} + +// stripZeroPort returns the address without a trailing ":0" port, +// handling both IPv4 and IPv6 formats. +export function stripZeroPort(address: string): string { + const { ip, port } = parseAddressPort(address); + if (port === "0" || port === undefined) return ip; + return address; +} diff --git a/src/cloud/webhooks/WebhookAuthenticationSettings.tsx b/src/cloud/webhooks/WebhookAuthenticationSettings.tsx new file mode 100644 index 0000000..1a79761 --- /dev/null +++ b/src/cloud/webhooks/WebhookAuthenticationSettings.tsx @@ -0,0 +1,128 @@ +import React from "react"; +import HelpText from "@components/HelpText"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { + SelectDropdown, + SelectOption, +} from "@components/select/SelectDropdown"; +import { + BracesIcon, + CircleOffIcon, + CircleUserIcon, + KeyRoundIcon, + TagIcon, + UserIcon, +} from "lucide-react"; +import { WebhookConfig } from "@/cloud/webhooks/useWebhookConfig"; + +export enum AuthType { + None = "none", + Basic = "basic", + Bearer = "bearer", + Custom = "custom", +} + +export const authenticationOptions = [ + { + value: AuthType.None, + label: "No Authentication", + icon: () => , + }, + { + value: AuthType.Basic, + label: "Basic Auth", + icon: () => , + }, + { + value: AuthType.Bearer, + label: "Bearer Token", + icon: () => , + }, + { + value: AuthType.Custom, + label: "Custom Authentication", + icon: () => , + }, +] as SelectOption[]; + +type AuthenticationSettingsProps = { + value: WebhookConfig; + mask?: boolean; +}; + +export const AuthenticationSettings = ({ + value, + mask, +}: AuthenticationSettingsProps) => ( + <> + + {value.authenticationType === AuthType.Basic && ( +
    + } + placeholder="Username" + value={value.username} + onChange={(e) => value.setUsername(e.target.value)} + data-testid="webhook-basic-username" + /> + } + placeholder="Password" + value={value.password} + onChange={(e) => value.setPassword(e.target.value)} + type={mask ? "password" : "text"} + data-testid="webhook-basic-password" + /> +
    + )} + + {value.authenticationType === AuthType.Bearer && ( +
    + value.setBearerToken(e.target.value)} + type={mask ? "password" : "text"} + data-testid="webhook-bearer-token" + /> +
    + )} + + {value.authenticationType === AuthType.Custom && ( +
    +
    +
    + + + Specify the header name and value for your custom authentication + +
    +
    + } + placeholder="e.g. X-API-Key" + value={value.customAuthName} + onChange={(e) => value.setCustomAuthName(e.target.value)} + data-testid="webhook-custom-auth-name" + /> + } + placeholder="e.g. AIiaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe" + type={mask ? "password" : "text"} + value={value.customAuthValue} + onChange={(e) => value.setCustomAuthValue(e.target.value)} + data-testid="webhook-custom-auth-value" + /> +
    +
    +
    + )} + +); diff --git a/src/cloud/webhooks/WebhookGeneralTabContent.tsx b/src/cloud/webhooks/WebhookGeneralTabContent.tsx new file mode 100644 index 0000000..c3f15ec --- /dev/null +++ b/src/cloud/webhooks/WebhookGeneralTabContent.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import HelpText from "@components/HelpText"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { TabsContent } from "@components/Tabs"; +import { GlobeIcon } from "lucide-react"; +import { AuthenticationSettings } from "@/cloud/webhooks/WebhookAuthenticationSettings"; +import { WebhookConfig } from "@/cloud/webhooks/useWebhookConfig"; + +type Props = { + value: WebhookConfig; + urlHelpText?: string; + mask?: boolean; +}; + +export function WebhookGeneralTabContent({ + value, + urlHelpText = "Full HTTP(S) URL where events will be sent via a POST request.", + mask, +}: Readonly) { + return ( + +
    + + {urlHelpText} + } + placeholder="https://api.example.com/webhook" + maxWidthClass="w-full" + value={value.url} + error={value.urlError} + onChange={(e) => value.setUrl(e.target.value)} + data-testid="webhook-url-input" + /> +
    + + + + Select your preferred authentication method for the endpoint. + + +
    + ); +} diff --git a/src/cloud/webhooks/WebhookHeadersInput.tsx b/src/cloud/webhooks/WebhookHeadersInput.tsx new file mode 100644 index 0000000..525152f --- /dev/null +++ b/src/cloud/webhooks/WebhookHeadersInput.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useState } from "react"; +import Button from "@components/Button"; +import { Input } from "@components/Input"; +import { uniqueId } from "lodash"; +import { MinusCircleIcon } from "lucide-react"; + +export interface ConfigHeader { + key: string; + value: string; + id: string; + error?: string; +} + +export enum ActionType { + ADD = "ADD", + REMOVE = "REMOVE", + UPDATE = "UPDATE", + SET_ALL = "SET_ALL", +} + +type AddAction = { type: ActionType.ADD }; +type RemoveAction = { type: ActionType.REMOVE; index: number }; +type UpdateAction = { + type: ActionType.UPDATE; + index: number; + header: ConfigHeader; +}; +type SetAllAction = { type: ActionType.SET_ALL; headers: ConfigHeader[] }; + +export type HeaderAction = + | AddAction + | RemoveAction + | UpdateAction + | SetAllAction; + +export function httpHeadersReducer( + state: ConfigHeader[], + action: HeaderAction, +): ConfigHeader[] { + switch (action.type) { + case ActionType.ADD: + return [ + ...state, + { key: "", value: "", id: uniqueId("header"), error: "" }, + ]; + case ActionType.REMOVE: + return state.filter((_, i) => i !== action.index); + case ActionType.UPDATE: + return state.map((h, i) => (i === action.index ? action.header : h)); + case ActionType.SET_ALL: + return action.headers; + default: + return state; + } +} + +export function HeadersInput({ + value, + onChange, + onRemove, + onError, + disabled, +}: Readonly<{ + value: ConfigHeader; + onChange: (header: ConfigHeader) => void; + onRemove: () => void; + onError?: (error: boolean) => void; + disabled?: boolean; +}>) { + const [key, setKey] = useState(value.key); + const [headerValue, setHeaderValue] = useState(value.value); + + const handleKeyChange = (e: React.ChangeEvent) => { + const newKey = e.target.value; + setKey(newKey); + + let error = ""; + if (newKey === "" && headerValue !== "") { + error = "Header key is required when value is provided"; + } + + onChange({ ...value, key: newKey, error }); + onError?.(!!error); + }; + + const handleValueChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setHeaderValue(newValue); + + let error = ""; + if (key === "" && newValue !== "") { + error = "Header name is required when a value is provided"; + } + + onChange({ ...value, value: newValue, error }); + onError?.(!!error); + }; + + useEffect(() => { + let error = ""; + if (key === "" && headerValue !== "") { + error = "Header name is required when a value is provided"; + onError?.(true); + } else { + onError?.(false); + } + if (value.error !== error) onChange({ ...value, error }); + return () => onError?.(false); + }, []); + + return ( +
    +
    +
    + +
    + +
    + +
    + + +
    +
    + ); +} diff --git a/src/cloud/webhooks/WebhookHeadersTabContent.tsx b/src/cloud/webhooks/WebhookHeadersTabContent.tsx new file mode 100644 index 0000000..afb6226 --- /dev/null +++ b/src/cloud/webhooks/WebhookHeadersTabContent.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import { Label } from "@components/Label"; +import { Callout } from "@components/Callout"; +import { TabsContent } from "@components/Tabs"; +import { AlertTriangleIcon, PlusIcon } from "lucide-react"; +import { + AuthType, +} from "@/cloud/webhooks/WebhookAuthenticationSettings"; +import { + ActionType, + HeadersInput, +} from "@/cloud/webhooks/WebhookHeadersInput"; +import { WebhookConfig } from "@/cloud/webhooks/useWebhookConfig"; + +type Props = { + value: WebhookConfig; +}; + +export function WebhookHeadersTabContent({ value }: Readonly) { + return ( + + + + If your endpoint requires additional headers, you can add them here. + + {value.httpHeaders.length > 0 && ( +
    +
    + {value.httpHeaders.map((header, i) => ( + + value.setHttpHeaders({ + type: ActionType.UPDATE, + index: i, + header: h, + }) + } + onRemove={() => + value.setHttpHeaders({ + type: ActionType.REMOVE, + index: i, + }) + } + onError={(error) => value.setHeaderError(error)} + /> + ))} +
    +
    + )} + + + {value.authHeaderConflict && ( + + } + className={"mt-5"} + > + Warning: You have added an {"'Authorization'"} header. This will + override the{" "} + + {value.authenticationType === AuthType.Basic + ? "Basic Auth" + : "Bearer Token"} + {" "} + authentication from the previous step. Please remove the{" "} + {"'Authorization'"} header. + + )} +
    + ); +} diff --git a/src/cloud/webhooks/useWebhookConfig.tsx b/src/cloud/webhooks/useWebhookConfig.tsx new file mode 100644 index 0000000..71a10a7 --- /dev/null +++ b/src/cloud/webhooks/useWebhookConfig.tsx @@ -0,0 +1,167 @@ +import { useEffect, useMemo, useReducer, useState } from "react"; +import { validator } from "@utils/helpers"; +import { uniqueId } from "lodash"; +import { AuthType } from "@/cloud/webhooks/WebhookAuthenticationSettings"; +import { + ActionType, + ConfigHeader, + httpHeadersReducer, +} from "@/cloud/webhooks/WebhookHeadersInput"; + +type UseWebhookConfigOptions = { + initialUrl?: string; + initialHeaders?: Record; +}; + +export type WebhookConfig = ReturnType; + +export function useWebhookConfig({ + initialUrl = "", + initialHeaders, +}: UseWebhookConfigOptions = {}) { + const [url, setUrl] = useState(initialUrl); + const [authenticationType, setAuthenticationType] = useState( + AuthType.None, + ); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [bearerToken, setBearerToken] = useState(""); + const [customAuthName, setCustomAuthName] = useState(""); + const [customAuthValue, setCustomAuthValue] = useState(""); + const [httpHeaders, setHttpHeaders] = useReducer(httpHeadersReducer, []); + const [headerError, setHeaderError] = useState(false); + const isEditing = !!initialUrl; + + useEffect(() => { + if (!initialHeaders) return; + try { + const headersObj = { ...initialHeaders }; + const authHeader = headersObj["Authorization"]; + if (authHeader?.startsWith("Basic ")) { + setAuthenticationType(AuthType.Basic); + setUsername("****"); + setPassword("****"); + delete headersObj["Authorization"]; + } else if (authHeader?.startsWith("Bearer ")) { + setAuthenticationType(AuthType.Bearer); + setBearerToken(authHeader.substring(7)); + delete headersObj["Authorization"]; + } + const headers = Object.entries(headersObj).map(([key, value]) => ({ + key, + value, + id: uniqueId("header"), + error: "", + })); + setHttpHeaders({ type: ActionType.SET_ALL, headers }); + } catch (e) { + /* empty */ + } + }, []); + + const urlError = useMemo(() => { + if (url === "") return ""; + if (!validator.isValidUrl(url)) { + return "Please enter a valid url, e.g., https://api.example.com/webhook"; + } + return ""; + }, [url]); + + const authHeaderConflict = useMemo(() => { + return ( + httpHeaders.some( + (header) => header.key.toLowerCase() === "authorization", + ) && + (authenticationType === AuthType.Basic || + authenticationType === AuthType.Bearer) + ); + }, [httpHeaders, authenticationType]); + + const canContinueToHeaders = useMemo(() => { + if (!url || urlError !== "") return false; + switch (authenticationType) { + case AuthType.None: + return true; + case AuthType.Basic: + return username !== "" && password !== ""; + case AuthType.Bearer: + return bearerToken !== ""; + case AuthType.Custom: + return customAuthName !== "" && customAuthValue !== ""; + default: + return false; + } + }, [ + url, + urlError, + authenticationType, + username, + password, + bearerToken, + customAuthName, + customAuthValue, + ]); + + const canSave = canContinueToHeaders && !headerError && !authHeaderConflict; + + const formatHeaders = ( + options?: { undefinedOnEmpty?: boolean }, + ): Record | undefined => { + const headersObject = httpHeaders.reduce( + (obj: Record, header) => { + if (header.key && header.value) { + obj[header.key] = header.value; + } + return obj; + }, + {}, + ); + if (authenticationType === AuthType.Basic) { + let credentials = btoa(`${username}:${password}`); + if (username.includes("****") || password.includes("****")) { + credentials = "****"; + } + headersObject["Authorization"] = `Basic ${credentials}`; + } else if (authenticationType === AuthType.Bearer) { + headersObject["Authorization"] = `Bearer ${bearerToken}`; + } + if (authenticationType === AuthType.Custom) { + headersObject[customAuthName] = customAuthValue; + } + if (options?.undefinedOnEmpty && Object.keys(headersObject).length === 0) { + return; + } + return headersObject; + }; + + return { + // URL + url, + setUrl, + urlError, + // Auth + authenticationType, + setAuthenticationType, + username, + setUsername, + password, + setPassword, + bearerToken, + setBearerToken, + customAuthName, + setCustomAuthName, + customAuthValue, + setCustomAuthValue, + // Headers + httpHeaders, + setHttpHeaders, + headerError, + setHeaderError, + // Derived + isEditing, + authHeaderConflict, + canContinueToHeaders, + canSave, + formatHeaders, + }; +} diff --git a/src/components/Accordion.tsx b/src/components/Accordion.tsx index c3f9573..450ec20 100644 --- a/src/components/Accordion.tsx +++ b/src/components/Accordion.tsx @@ -47,7 +47,8 @@ const AccordionContent = React.forwardRef< const el = wrapperRef.current?.closest("[data-state]"); if (!el) return; - const update = () => setIsOpen(el.getAttribute("data-state") === "open"); + const update = () => + setIsOpen(el.getAttribute("data-state") === "open"); update(); const observer = new MutationObserver(update); diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index 2ebb663..36f97a6 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -44,7 +44,7 @@ export const Item = ({ > {icon && icon} {href ? ( - + {label} ) : ( diff --git a/src/components/Code.tsx b/src/components/Code.tsx index 6dd6b5f..ff7c928 100644 --- a/src/components/Code.tsx +++ b/src/components/Code.tsx @@ -66,7 +66,7 @@ export default function Code({ {copied ? : } diff --git a/src/components/DeviceCard.tsx b/src/components/DeviceCard.tsx index 9f0b74c..827b3e6 100644 --- a/src/components/DeviceCard.tsx +++ b/src/components/DeviceCard.tsx @@ -29,7 +29,7 @@ export const DeviceCard = ({ return description !== undefined ? description : address || device?.ip || resource?.address; - }, [description, address, device]); + }, [description, address, device, resource]); return (
    diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx index 514fdd4..d18d33b 100644 --- a/src/components/PeerGroupSelector.tsx +++ b/src/components/PeerGroupSelector.tsx @@ -78,7 +78,7 @@ interface MultiSelectProps { saveGroupAssignments?: boolean; showRoutes?: boolean; disabledGroups?: Group[]; - dataCy?: string; + "data-testid"?: string; showResourceCounter?: boolean; showResources?: boolean; showPeers?: boolean; @@ -119,7 +119,7 @@ export function PeerGroupSelector({ saveGroupAssignments = true, showRoutes = false, disabledGroups, - dataCy = "group-selector-dropdown", + "data-testid": dataTestId = "group-selector-dropdown", showResourceCounter = true, showResources = false, showPeers = false, @@ -169,8 +169,16 @@ export function PeerGroupSelector({ const [open, setOpen] = useState(false); + const visibleDropdownOptions = useMemo( + () => + hideAllGroup + ? dropdownOptions.filter((g) => g.name !== "All") + : dropdownOptions, + [dropdownOptions, hideAllGroup], + ); + const sortedDropdownOptions = useSortedDropdownOptions( - dropdownOptions, + visibleDropdownOptions, values, open, ); @@ -192,10 +200,6 @@ export function PeerGroupSelector({ let uniqueGroups = unionBy(sortedGroups, dropdownOptions, "name"); uniqueGroups = unionBy(clientGroups, uniqueGroups, "name"); - uniqueGroups = hideAllGroup - ? uniqueGroups.filter((group) => group.name !== "All") - : uniqueGroups; - setDropdownOptions(uniqueGroups); // eslint-disable-next-line react-hooks/exhaustive-deps }, [groups]); @@ -389,7 +393,7 @@ export function PeerGroupSelector({ "disabled:pointer-events-none disabled:opacity-60 transition-all", )} disabled={disabled} - data-cy={dataCy} + data-testid={dataTestId} ref={inputRef} >
    -
    +
    { @@ -1205,7 +1209,7 @@ const PeersList = ({
    @@ -187,7 +187,7 @@ export function PortSelector({ "bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0", "dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10", )} - data-cy={"port-input"} + data-testid={"port-input"} ref={searchRef} value={search} onValueChange={setSearch} diff --git a/src/components/SegmentedTabs.tsx b/src/components/SegmentedTabs.tsx index 76e080a..7a1a1c2 100644 --- a/src/components/SegmentedTabs.tsx +++ b/src/components/SegmentedTabs.tsx @@ -45,16 +45,19 @@ function Trigger({ value, disabled = false, className, + "data-testid": dataTestId, }: { children: React.ReactNode; value: string; disabled?: boolean; className?: string; + "data-testid"?: string; }) { const currentValue = useTabContext(); return ( void; + "data-testid"?: string; disabled?: boolean; }; @@ -21,6 +22,7 @@ function SettingCardItem({ description, enabled, onClick, + "data-testid": dataTestId, disabled = false, }: Readonly) { const handleClick = () => { @@ -40,8 +42,9 @@ function SettingCardItem({ onClick(); } }} + data-testid={dataTestId} className={cn( - "flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 transition-colors", + "flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 transition-colors w-full", disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-nb-gray-935 cursor-pointer", @@ -108,7 +111,9 @@ function SettingCard({ children, className }: Readonly) { ); } -const SettingCardWithItem = SettingCard as React.FC> & { +const SettingCardWithItem = SettingCard as React.FC< + Readonly +> & { Item: typeof SettingCardItem; }; SettingCardWithItem.Item = SettingCardItem; diff --git a/src/components/SidebarItem.tsx b/src/components/SidebarItem.tsx index b27f577..9aba79d 100644 --- a/src/components/SidebarItem.tsx +++ b/src/components/SidebarItem.tsx @@ -99,7 +99,7 @@ export default function SidebarItem({ if (!visible) return; return ( - +
  • diff --git a/src/components/table/DataTableRowsPerPage.tsx b/src/components/table/DataTableRowsPerPage.tsx index 9f0be38..275ca28 100644 --- a/src/components/table/DataTableRowsPerPage.tsx +++ b/src/components/table/DataTableRowsPerPage.tsx @@ -30,7 +30,7 @@ export function DataTableRowsPerPage({ role="combobox" aria-expanded={open} disabled={disabled} - data-cy={"rows-per-page"} + data-testid={"rows-per-page"} className="w-[200px] justify-between" > @@ -50,7 +50,7 @@ export function DataTableRowsPerPage({ { table.setPageSize(Number(currentValue)); setOpen(false); diff --git a/src/components/table/TableFilters.tsx b/src/components/table/TableFilters.tsx index 8a76f0b..8548f43 100644 --- a/src/components/table/TableFilters.tsx +++ b/src/components/table/TableFilters.tsx @@ -63,7 +63,11 @@ export function TableFiltersButton({ }} > - - - - - +return ( + + + + + + ); } diff --git a/src/components/ui/AnnouncementBanner.tsx b/src/components/ui/AnnouncementBanner.tsx index de1eb89..3fca3fb 100644 --- a/src/components/ui/AnnouncementBanner.tsx +++ b/src/components/ui/AnnouncementBanner.tsx @@ -2,7 +2,7 @@ import InlineLink from "@components/InlineLink"; import { cn } from "@utils/helpers"; import { cva, VariantProps } from "class-variance-authority"; import { ArrowRightIcon, XIcon } from "lucide-react"; -import * as React from "react"; +import { useEffect, useRef } from "react"; import { useAnnouncement } from "@/contexts/AnnouncementProvider"; const variants = cva( @@ -36,22 +36,38 @@ const variants = cva( export type AnnouncementVariant = VariantProps; export const AnnouncementBanner = () => { - const { bannerHeight, closeAnnouncement, announcements } = useAnnouncement(); + const { closeAnnouncement, announcements, setBannerHeight } = + useAnnouncement(); const announcement = announcements?.find((a) => a.isOpen); + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el || !announcement) { + setBannerHeight(0); + return; + } + const measure = () => + setBannerHeight(Math.ceil(el.getBoundingClientRect().height)); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [announcement, setBannerHeight]); return announcement ? (
    -
    +
    {announcement.tag && (
    @@ -59,13 +75,13 @@ export const AnnouncementBanner = () => {
    )}
    - {announcement.text} + {announcement.text} {announcement.link && ( diff --git a/src/components/ui/FullScreenLoading.tsx b/src/components/ui/FullScreenLoading.tsx index 9443814..2199213 100644 --- a/src/components/ui/FullScreenLoading.tsx +++ b/src/components/ui/FullScreenLoading.tsx @@ -2,7 +2,7 @@ import { cn } from "@utils/helpers"; import LoadingIcon from "@/assets/icons/LoadingIcon"; type Props = { - fullScreen?: boolean + fullScreen?: boolean; }; export default function FullScreenLoading({ fullScreen = true }: Props) { return ( diff --git a/src/components/ui/GroupBadge.tsx b/src/components/ui/GroupBadge.tsx index 3901613..9f4eb8b 100644 --- a/src/components/ui/GroupBadge.tsx +++ b/src/components/ui/GroupBadge.tsx @@ -53,7 +53,7 @@ export default function GroupBadge({ { diff --git a/src/components/ui/HelpAndSupportButton.tsx b/src/components/ui/HelpAndSupportButton.tsx index f6426ee..b9b9567 100644 --- a/src/components/ui/HelpAndSupportButton.tsx +++ b/src/components/ui/HelpAndSupportButton.tsx @@ -22,7 +22,7 @@ import { useState } from "react"; import Button from "@components/Button"; import { cn } from "@utils/helpers"; import SlackIcon from "@/assets/icons/SlackIcon"; -import { isNetBirdHosted } from "@utils/netbird"; +import { isNetBirdCloud } from "@utils/netbird"; export default function HelpAndSupportButton() { const [dropdownOpen, setDropdownOpen] = useState(false); @@ -83,7 +83,7 @@ export default function HelpAndSupportButton() { - {isNetBirdHosted() && ( + {isNetBirdCloud() && (
    diff --git a/src/components/ui/InputDomain.tsx b/src/components/ui/InputDomain.tsx index 3f64693..29f036b 100644 --- a/src/components/ui/InputDomain.tsx +++ b/src/components/ui/InputDomain.tsx @@ -82,7 +82,7 @@ export default function InputDomain({ customPrefix={} placeholder={"e.g., example.com"} maxWidthClass={"w-full"} - data-cy={"domain-input"} + data-testid={"domain-input"} value={name} error={domainError} onChange={handleNameChange} @@ -96,6 +96,7 @@ export default function InputDomain({ variant={"default-outline"} onClick={onRemove} disabled={disabled} + data-testid="domain-input-remove" > diff --git a/src/components/ui/MultipleGroups.tsx b/src/components/ui/MultipleGroups.tsx index f484a98..cc364cd 100644 --- a/src/components/ui/MultipleGroups.tsx +++ b/src/components/ui/MultipleGroups.tsx @@ -73,7 +73,7 @@ export default function MultipleGroups({
    {countOnly && orderedGroups.length > countThreshold ? ( diff --git a/src/components/ui/PeerCountBadge.tsx b/src/components/ui/PeerCountBadge.tsx index 0864852..f31ca48 100644 --- a/src/components/ui/PeerCountBadge.tsx +++ b/src/components/ui/PeerCountBadge.tsx @@ -37,8 +37,7 @@ export default function PeerCountBadge({ return peerCount; }, [currentGroup]); - const canRedirect = - !!group?.id && group?.name !== "All" && !disableRedirect; + const canRedirect = !!group?.id && group?.name !== "All" && !disableRedirect; const onClick = (e: React.MouseEvent) => { e.stopPropagation(); diff --git a/src/components/ui/PolicyDirection.tsx b/src/components/ui/PolicyDirection.tsx index f2ba369..38ac9b7 100644 --- a/src/components/ui/PolicyDirection.tsx +++ b/src/components/ui/PolicyDirection.tsx @@ -80,7 +80,7 @@ export default function PolicyDirection({ className, )} onClick={toggleDirection} - data-cy={"policy-direction"} + data-testid={"policy-direction"} > - - - - - -
    -
    - + + + + + +
    +
    + +
    +
    + +
    -
    - -
    -
    - + - + - {!isRestricted && ( - { - setDropdownOpen(false); - if (loggedInUser) { - router.push(`/team/user?id=${loggedInUser.id}`); - } - }} - /> - )} + {permission?.billing?.update && ( + { + setDropdownOpen(false); + router.push("/settings?tab=plans-and-billing"); + }} + /> + )} - {!isNetBirdHosted() && loggedInUser?.idp_id === "local" && ( + {!isRestricted && ( + { + setDropdownOpen(false); + if (loggedInUser) { + router.push(`/team/user?id=${loggedInUser.id}`); + } + }} + /> + )} + + {!isNetBirdCloud() && loggedInUser?.idp_id === "local" && ( { setDropdownOpen(false); @@ -101,20 +111,28 @@ export default function UserDropdown() { )} - -
    - - Log out -
    - {isMac ? "⇧⌘L" : "⇧ ⊞ L"} -
    - - + +
    + + Log out +
    + + {isMac ? "⇧⌘L" : "⇧ ⊞ L"} + +
    + + ); } const ProfileSettingsDropdownItem = ({ onClick }: { onClick: () => void }) => { + const { isMSPInTenantContext } = useMSP(); + const { permission } = usePermissions(); + + if (isMSPInTenantContext) return; + if (!permission?.users.read) return; + return (
    @@ -124,3 +142,21 @@ const ProfileSettingsDropdownItem = ({ onClick }: { onClick: () => void }) => { ); }; + +const PlansAndBillingDropdownItem = ({ onClick }: { onClick: () => void }) => { + const { permission } = usePermissions(); + + const { isAccountWithMSPParent } = useMSP(); + if (isAccountWithMSPParent) return; + + return ( + permission?.billing?.update && ( + +
    + + Plans & Billing +
    +
    + ) + ); +}; diff --git a/src/contexts/AnnouncementProvider.tsx b/src/contexts/AnnouncementProvider.tsx index 7f8ad3b..5caab58 100644 --- a/src/contexts/AnnouncementProvider.tsx +++ b/src/contexts/AnnouncementProvider.tsx @@ -7,15 +7,29 @@ import React, { useRef, useState, } from "react"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { trialExpiresInfo, usageLimitInfo } from "@/contexts/BillingProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; -import { isLocalDev, isNetBirdHosted } from "@utils/netbird"; -import announcementFile from "../../announcements.json"; +import { isNetBirdCloud } from "@utils/netbird"; const ANNOUNCEMENTS_URL = "https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json"; const STORAGE_KEY = "netbird-announcements"; const CACHE_DURATION_MS = 30 * 60 * 1000; -const BANNER_HEIGHT = 40; + +// MSP only +const initialMSPAnnouncements: Announcement[] = [ + { + tag: "New", + text: "Huntress now integrates with NetBird", + link: "https://docs.netbird.io/manage/access-control/endpoint-detection-and-response/huntress-edr", + linkText: "Learn more", + variant: "default", + isExternal: true, + closeable: true, + isCloudOnly: true, + }, +]; interface AnnouncementStore { timestamp: number; @@ -45,6 +59,7 @@ type Props = { const AnnouncementContext = createContext( {} as { bannerHeight: number; + setBannerHeight: (height: number) => void; announcements?: AnnouncementInfo[]; closeAnnouncement: (hash: string) => void; setAnnouncements: React.Dispatch< @@ -53,7 +68,10 @@ const AnnouncementContext = createContext( }, ); -const getAnnouncements = async (): Promise => { +const getRemoteAnnouncements = async (): Promise<{ + announcements: Announcement[]; + closedAnnouncements: string[]; +}> => { try { let stored: AnnouncementStore | null = null; try { @@ -65,18 +83,20 @@ const getAnnouncements = async (): Promise => { let raw: Announcement[]; - if (isLocalDev()) { - raw = announcementFile as Announcement[]; - } else if (stored && now - stored.timestamp < CACHE_DURATION_MS) { + if (stored && now - stored.timestamp < CACHE_DURATION_MS) { raw = stored.announcements; } else { const response = await fetch(ANNOUNCEMENTS_URL); - if (!response.ok) return []; - + if (!response.ok) { + return { + announcements: [], + closedAnnouncements: stored?.closedAnnouncements ?? [], + }; + } raw = await response.json(); } - const isCloud = isNetBirdHosted(); + const isCloud = isNetBirdCloud(); const filtered = raw.filter((a) => !a.isCloudOnly || isCloud); const hashes = new Set(filtered.map((a) => md5(a.text).toString())); const closed = (stored?.closedAnnouncements ?? []).filter((h) => @@ -94,16 +114,13 @@ const getAnnouncements = async (): Promise => { ); } catch {} - return filtered.map((a) => { - const hash = md5(a.text).toString(); - return { ...a, hash, isOpen: !closed.includes(hash) }; - }); + return { announcements: filtered, closedAnnouncements: closed }; } catch { - return []; + return { announcements: [], closedAnnouncements: [] }; } }; -const saveAnnouncements = (closedAnnouncements: string[]) => { +const saveClosedAnnouncements = (closedAnnouncements: string[]) => { try { const data = localStorage.getItem(STORAGE_KEY); const stored: AnnouncementStore | null = data ? JSON.parse(data) : null; @@ -118,17 +135,73 @@ const saveAnnouncements = (closedAnnouncements: string[]) => { export default function AnnouncementProvider({ children }: Readonly) { const [announcements, setAnnouncements] = useState(); + const [measuredHeight, setMeasuredHeight] = useState(0); const { isRestricted } = usePermissions(); + const { isMSPInTenantContext, isMSPInMSPContext, isMspInfoLoading } = + useMSP(); const fetchingRef = useRef(false); useEffect(() => { if (announcements !== undefined || isRestricted || fetchingRef.current) return; + if (isMspInfoLoading) return; + fetchingRef.current = true; - getAnnouncements() - .then((a) => setAnnouncements(a)) + + getRemoteAnnouncements() + .then(({ announcements: remoteAnnouncements, closedAnnouncements }) => { + const isCloud = isNetBirdCloud(); + + // Start with remote announcements + let allAnnouncements: AnnouncementInfo[] = remoteAnnouncements.map( + (a) => { + const hash = md5(a.text).toString(); + return { + ...a, + hash, + isOpen: !closedAnnouncements.includes(hash), + }; + }, + ); + + // Add MSP announcements if in MSP context + if (isMSPInTenantContext || isMSPInMSPContext) { + const mspAnnouncements = initialMSPAnnouncements + .filter((a) => !a.isCloudOnly || isCloud) + .map((a) => { + const hash = md5(a.text).toString(); + return { + ...a, + hash, + isOpen: !closedAnnouncements.includes(hash), + }; + }); + allAnnouncements.unshift(...mspAnnouncements); + } + + // Add billing announcements (initially closed, opened by BillingProvider) + allAnnouncements.unshift({ + ...trialExpiresInfo, + hash: md5(trialExpiresInfo.text).toString(), + isOpen: false, + }); + + allAnnouncements.unshift({ + ...usageLimitInfo, + hash: md5(usageLimitInfo.text).toString(), + isOpen: false, + }); + + setAnnouncements(allAnnouncements); + }) .finally(() => (fetchingRef.current = false)); - }, [announcements, isRestricted]); + }, [ + announcements, + isRestricted, + isMspInfoLoading, + isMSPInTenantContext, + isMSPInMSPContext, + ]); const closeAnnouncement = (hash: string) => { if (!announcements) return; @@ -138,16 +211,19 @@ export default function AnnouncementProvider({ children }: Readonly) { const closedAnnouncements = updated .filter((a) => !a.isOpen) .map((a) => a.hash); - saveAnnouncements(closedAnnouncements); + saveClosedAnnouncements(closedAnnouncements); setAnnouncements(updated); }; - const bannerHeight = announcements?.some((a) => a.isOpen) ? BANNER_HEIGHT : 0; + const bannerHeight = announcements?.some((a) => a.isOpen) + ? measuredHeight + : 0; return ( Promise; + subscribe: (plan: Plan, aws?: boolean) => Promise; + changeSubscription: (plan: Plan, aws?: boolean) => Promise; + canUpgrade: boolean; + isDowngrade: (plan: Plan) => boolean; + usagePercentage: number; + isTrial?: boolean; + isTrialAvailable?: boolean; + startTrial: ( + plan: Plan, + feature?: keyof typeof PlanFeatures, + ) => Promise; + trialDaysRemaining: number; + currency: Currency; + setCurrency: (currency: Currency) => void; + getCurrentPlanByPlanTier: (planTier: PlanTier) => Plan | undefined; + calculateEstimatedPrice: ( + plan: Plan, + currency: Currency, + usage: AccountUsageStats, + ) => number; + isAWS: boolean; + }, +); + +export default function BillingProvider({ children }: Props) { + const { permission } = usePermissions(); + + return permission?.billing?.read && isNetBirdCloud() ? ( + {children} + ) : ( + <>{children} + ); +} + +function BillingContextProvider({ children }: Readonly) { + const baseURL = `${window.location.protocol}//${window.location.host}`; + const router = useRouter(); + const currentPath = usePathname(); + const currentTab = useSearchParams().get("tab"); + const { mutate } = useSWRConfig(); + const { confirm } = useDialog(); + const { trackEvent } = useAnalytics(); + const { setAnnouncements } = useAnnouncement(); + const freeUsers = 0; + + const redirectUrl = useMemo(() => { + return `${baseURL}${currentPath}?tab=${currentTab}`; + }, [baseURL, currentPath, currentTab]); + + // Usage stats + const { data: stats, isLoading: isStatsLoading } = + useFetchApi("/integrations/billing/usage", false); + + // Current subscription status + const { data: subscription, isLoading: isSubscriptionLoading } = + useFetchApi("/integrations/billing/subscription", false); + + // Plans + const { data: plans, isLoading: isPlanLoading } = useFetchApi( + "/integrations/billing/plans", + false, + ); + + const checkout = useApiCall("/integrations/billing/checkout").post; + const changeSubscriptionRequest = useApiCall( + "/integrations/billing/subscription", + ).put; + const customerPortal = useApiCall("/integrations/billing/portal").get; + + const awsRequest = useApiCall( + "/integrations/billing/aws/marketplace/activate", + ).post; + + const isLoading = isStatsLoading || isSubscriptionLoading || isPlanLoading; + + const getCurrentPlanByPlanTier = useCallback( + (planTier: PlanTier) => { + if (!plans) return; + const freePlan = plans.find((plan) => + plan.name.toLowerCase().includes(PlanTier.FREE), + ); + return ( + plans.find( + (plan) => + plan.name.toLowerCase().includes(planTier) && + planTier != PlanTier.UNKNOWN, + ) || freePlan + ); + }, + [plans], + ); + + const currentPlan = useMemo(() => { + if (!subscription) return; + return getCurrentPlanByPlanTier(subscription.plan_tier); + }, [getCurrentPlanByPlanTier, subscription]); + + const currentPlanPrice = useMemo(() => { + return currentPlan?.prices.find( + (price) => price.price_id === subscription?.price_id, + ); + }, [currentPlan, subscription]); + + const initialCurrency = useRef(undefined); + + const [currency, setCurrency] = useState( + subscription?.currency || Currency.USD, + ); + + useEffect(() => { + if (isSubscriptionLoading) return; + + if (subscription?.active && subscription?.currency) { + setCurrency(subscription.currency); + initialCurrency.current = subscription.currency; + } else if (!initialCurrency.current) { + initialCurrency.current = subscription?.currency || Currency.USD; + setCurrency(initialCurrency.current); + } + }, [isSubscriptionLoading, subscription?.active, subscription?.currency]); + + const maxPeersOfPlan = useMemo(() => { + return ( + 100 + + Math.max( + currentPlan && !currentPlan.name.toLowerCase().includes("free") + ? ((stats?.active_users || 1) - freeUsers) * 10 + : 0, + 0, + ) + ); + }, [currentPlan, stats?.active_users]); + + const isFreePlan = currentPlan + ? currentPlan.name.toLowerCase().includes("free") + : true; + + const calculateEstimatedPrice = useCallback( + (plan: Plan, currency: Currency, usage: AccountUsageStats) => { + if (!plan || !usage || !currency) return 0; + if (plan.name.toLowerCase().includes(PlanTier.FREE)) return 0; + if (plan.name.toLowerCase().includes(PlanTier.TRIAL)) return 0; + const subscriptionPrice = plan.prices.find( + (price) => price.currency === currency, + ); + if (!subscriptionPrice) return 0; + + const machinesPerUser = 10; + let machinesIncluded = 100; + + const activeUsers = usage.active_users || 0; + const activeMachines = usage.active_peers || 0; + + const pricePerUser = subscriptionPrice.price / 100; + const pricePerMachine = 0.5; + + machinesIncluded = machinesIncluded + activeUsers * machinesPerUser; + const billableMachines = Math.max(activeMachines - machinesIncluded, 0); + const billableUsers = Math.max(activeUsers, 0); + + const machinesCost = billableMachines * pricePerMachine; + const usersCost = billableUsers * pricePerUser; + + return machinesCost + usersCost; + }, + [], + ); + + const estimatedPrice = useMemo(() => { + if (!currentPlan || isFreePlan || !currentPlanPrice || !stats) return 0; + return calculateEstimatedPrice(currentPlan, currency, stats); + }, [ + currentPlan, + isFreePlan, + currentPlanPrice, + stats, + calculateEstimatedPrice, + currency, + ]); + + const subscribe = async (plan: Plan, aws?: boolean) => { + const priceID = plan.prices.find((price) => price.currency === currency) + ?.price_id; + if (!priceID) return Promise.reject(); + + let promise; + + if (aws) { + try { + promise = awsRequest({ + plan_tier: plan.name.toLowerCase(), + }).then((res) => { + mutate("/integrations/billing/subscription"); + }); + notify({ + title: "NetBird Subscription", + description: `Successfully subscribed to the ${plan.name} plan`, + loadingMessage: "Subscribing to NetBird via AWS Marketplace...", + promise: promise, + }); + return promise; + } catch (err) {} + } else { + return checkout({ baseURL: redirectUrl, priceID }).then((response) => { + if (response && response.url) { + trackEvent( + "Billing", + `subscribe_${plan.name}`, + `${plan.name} (${priceID})`, + ); + router.push(response.url); + } + }); + } + }; + + const changeSubscription = async (plan: Plan, aws = false) => { + const priceID = plan.prices.find((price) => price.currency === currency) + ?.price_id; + if (!priceID) return Promise.reject(); + const downgrade = isDowngrade(plan); + const choice = await confirm({ + title: `${downgrade ? "Downgrade" : "Upgrade"} to ${plan.name}?`, + description: ( +
    +
    + The transition to your new plan will take effect immediately. + Charges for the new plan will be incurred from this point forward. +
    +
    + ), + confirmText: downgrade ? "Downgrade" : "Upgrade", + cancelText: "Cancel", + type: "default", + }); + if (!choice) return; + if (currentPlanPrice?.price_id === priceID) return Promise.reject(); + const planTier = plan.name.toLowerCase(); + + const promise = changeSubscriptionRequest({ + priceID: aws ? undefined : priceID, + plan_tier: aws ? planTier : undefined, + }).then(() => { + trackEvent( + "Billing", + `subscription_${downgrade ? "downgrade" : "upgrade"}_to_${plan.name}`, + `Subscription ${downgrade ? "downgrade" : "upgrade"} to ${ + plan.name + } (${priceID})`, + ); + mutate("/integrations/billing/subscription"); + }); + + notify({ + title: "Update Subscription", + description: "Your subscription has been successfully updated.", + promise: promise, + loadingMessage: "Updating your subscription...", + }); + + return promise; + }; + + const visitCustomerPortal = async () => { + return customerPortal(`?baseURL=${encodeURIComponent(redirectUrl)}`).then( + (response) => { + if (response && response.url) { + trackEvent("Billing", "subscription_visit_portal", "Manage Plan"); + router.push(response.url); + } + }, + ); + }; + + const canUpgrade = useMemo(() => { + if (subscription && !subscription.active) return true; + if (subscription && !subscription.updated_at) return true; + if (subscription && subscription.plan_tier === PlanTier.FREE) return true; + if (subscription && subscription.plan_tier === PlanTier.TRIAL) return true; + const updatedAt = dayjs(subscription?.updated_at); + const now = dayjs(); + const diff = now.diff(updatedAt, "hour"); + return diff >= 48; + }, [subscription]); + + const isDowngrade = useCallback( + (plan: Plan) => { + if (!currentPlanPrice) return false; + const price = plan.prices.find((price) => price.currency === currency) + ?.price; + if (!price) return false; + return price < currentPlanPrice.price; + }, + [currentPlanPrice, currency], + ); + + const usagePercentage = useMemo(() => { + if (stats) { + const maxUsers = 5; + const activeUsersPercentage = (stats.active_users / maxUsers) * 100; + const activePeersPercentage = (stats.active_peers / maxPeersOfPlan) * 100; + if (isFreePlan) + return Math.max(activeUsersPercentage, activePeersPercentage); + return activePeersPercentage; + } + return 0; + }, [isFreePlan, maxPeersOfPlan, stats]); + + const [trialSuccessModal, setTrialSuccessModal] = useState(false); + + const isTrial = useMemo(() => { + if (isSubscriptionLoading && !subscription) return undefined; + if (subscription?.plan_tier === "business") return false; + if (subscription?.plan_tier === "enterprise") return false; + if (subscription?.remaining_trial === undefined) return false; + return subscription.remaining_trial > 0; + }, [subscription, isSubscriptionLoading]); + + const isAWS = useMemo(() => { + return subscription?.provider === "aws"; + }, [subscription]); + + const isTrialAvailable = useMemo(() => { + if (isSubscriptionLoading && !subscription) return undefined; + if (isAWS) return false; + if (subscription?.plan_tier === "business") return false; + if (subscription?.plan_tier === "enterprise") return false; + return subscription?.remaining_trial === undefined; + }, [subscription, isSubscriptionLoading]); + + const startTrial = async ( + plan: Plan, + feature?: keyof typeof PlanFeatures, + ) => { + const priceID = plan.prices.find((price) => price.currency === currency) + ?.price_id; + if (!priceID) return Promise.reject("Invalid plan"); + return checkout({ + baseURL: redirectUrl, + priceID, + enableTrial: true, + }) + .then((response) => { + setTrialSuccessModal(true); + trackEvent( + "Billing", + `trial_started${feature && `_on_${feature.toLowerCase()}`}`, + "Trial Started", + ); + mutate("/integrations/billing/subscription"); + return true; + }) + .catch(() => false); + }; + + const trialDaysRemaining = useMemo(() => { + if (!subscription || subscription.remaining_trial === undefined) return 0; + return Math.ceil(subscription.remaining_trial / 86400); + }, [subscription]); + + useEffect(() => { + if (isLoading) return; + if (isTrial && trialDaysRemaining <= 3) { + setAnnouncements((prev) => { + const prevAnnouncements = prev || []; + const hash = md5(trialExpiresInfo.text).toString(); + return prevAnnouncements.map((a) => { + if (a.hash === hash) { + return { ...a, isOpen: true }; + } + return a; + }); + }); + } + }, [isTrial, setAnnouncements, isLoading, trialDaysRemaining]); + + useEffect(() => { + if (isLoading) return; + if (usagePercentage > 100 && isFreePlan && !isTrial) { + setAnnouncements((prev) => { + const prevAnnouncements = prev || []; + const usageInfoHash = md5(usageLimitInfo.text).toString(); + return prevAnnouncements.map((a) => { + if (a.hash === usageInfoHash) { + return { ...a, isOpen: true }; + } + return a; + }); + }); + } + }, [isFreePlan, usagePercentage, setAnnouncements, isLoading, isTrial]); + + return ( + + + + + {children} + + ); +} + +export const useBilling = () => { + return React.useContext(BillingContext); +}; diff --git a/src/contexts/DialogProvider.tsx b/src/contexts/DialogProvider.tsx index 64688ba..77e7d27 100644 --- a/src/contexts/DialogProvider.tsx +++ b/src/contexts/DialogProvider.tsx @@ -79,7 +79,11 @@ export default function DialogProvider({ children }: Props) { dialogOptions.description || "Are you sure you want to continue? This action cannot be undone." } - icon={dialogOptions.hideIcon ? "" : dialogTypes[dialogOptions.type || "default"]} + icon={ + dialogOptions.hideIcon + ? "" + : dialogTypes[dialogOptions.type || "default"] + } color={ dialogOptions.type == "default" ? "blue" @@ -104,7 +108,7 @@ export default function DialogProvider({ children }: Props) { className={"w-full"} size={"sm"} tabIndex={-1} - data-cy={"confirmation.cancel"} + data-testid={"confirmation.cancel"} onClick={() => fn.current && fn.current(false)} > {dialogOptions.cancelText || "Cancel"} @@ -120,7 +124,7 @@ export default function DialogProvider({ children }: Props) { } className={"w-full"} size={"sm"} - data-cy={"confirmation.confirm"} + data-testid={"confirmation.confirm"} onClick={() => fn.current && fn.current(true)} > {dialogOptions.confirmText || "Confirm"} diff --git a/src/contexts/InstanceSetupProvider.tsx b/src/contexts/InstanceSetupProvider.tsx index b132687..796e9b5 100644 --- a/src/contexts/InstanceSetupProvider.tsx +++ b/src/contexts/InstanceSetupProvider.tsx @@ -4,7 +4,7 @@ import { usePathname, useRouter } from "next/navigation"; import React, { createContext, useContext, useEffect, useState } from "react"; import FullScreenLoading from "@/components/ui/FullScreenLoading"; import { fetchInstanceStatus } from "@/utils/unauthenticatedApi"; -import { isNetBirdHosted } from "@utils/netbird"; +import { isNetBirdCloud } from "@utils/netbird"; interface InstanceSetupContextType { setupRequired: boolean; @@ -40,7 +40,7 @@ export default function InstanceSetupProvider({ const shouldBypass = bypassRoutes.includes(pathname) || isOIDCCallback(); // Skip setup check for NetBird hosted (cloud) deployments - const isCloud = isNetBirdHosted(); + const isCloud = isNetBirdCloud(); const isSetupPage = pathname === "/setup"; // Check instance status on mount diff --git a/src/contexts/PermissionsProvider.tsx b/src/contexts/PermissionsProvider.tsx index 4856dee..4f2bd71 100644 --- a/src/contexts/PermissionsProvider.tsx +++ b/src/contexts/PermissionsProvider.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from "react"; -import { Permissions } from "@/interfaces/Permission"; +import { Permission, Permissions } from "@/interfaces/Permission"; import { User } from "@/interfaces/User"; type Props = { @@ -14,6 +14,55 @@ const PermissionsContext = React.createContext( }, ); +const MODULE_KEYS: Array = [ + "peers", + "groups", + "setup_keys", + "policies", + "assistant", + "networks", + "routes", + "nameservers", + "dns", + "users", + "pats", + "events", + "settings", + "accounts", + "billing", + "identity_providers", + "edr", + "event_streaming", + "idp", + "msp", + "tenants", + "proxy", + "proxy_configuration", + "services", +]; + +const DENIED: Permission = { + create: false, + read: false, + update: false, + delete: false, +}; + +/** + * Fills in modules absent from the management response, e.g. premium modules + * that the open-source management server does not report, so consumers can + * read permission flags without guarding against undefined modules. + */ +const withDefaultModules = ( + modules: Permissions["modules"], +): Permissions["modules"] => { + const complete = { ...modules }; + MODULE_KEYS.forEach((key) => { + if (!complete[key]) complete[key] = { ...DENIED }; + }); + return complete; +}; + export default function PermissionsProvider({ children, user, @@ -25,7 +74,7 @@ export default function PermissionsProvider({ const data = useMemo(() => { return { isRestricted: permissions.is_restricted, - permission: permissions.modules, + permission: withDefaultModules(permissions.modules), }; }, [permissions]); diff --git a/src/contexts/PoliciesProvider.tsx b/src/contexts/PoliciesProvider.tsx index 02da78b..26f7ddc 100644 --- a/src/contexts/PoliciesProvider.tsx +++ b/src/contexts/PoliciesProvider.tsx @@ -70,9 +70,9 @@ export default function PoliciesProvider({ children }: Props) { const destinations = rule.destinationResource ? undefined - : await Promise.all((rule.destinations ?? []).map(resolveGroup)).then( - (ids) => ids.filter(Boolean) as string[], - ); + : await Promise.all( + (rule.destinations ?? []).map(resolveGroup), + ).then((ids) => ids.filter(Boolean) as string[]); const destinationResource = rule.destinationResource ? { id: resource.id, type: resource.type } diff --git a/src/contexts/ReverseProxiesProvider.tsx b/src/contexts/ReverseProxiesProvider.tsx index 9bb9b28..6935bec 100644 --- a/src/contexts/ReverseProxiesProvider.tsx +++ b/src/contexts/ReverseProxiesProvider.tsx @@ -26,6 +26,7 @@ import { } from "@/interfaces/ReverseProxy"; import ReverseProxyModal from "@/modules/reverse-proxy/ReverseProxyModal"; import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal"; +import { TerminatedProxiesProvider } from "@/cloud/reverse-proxy/TerminatedProxiesProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; type ReverseProxiesContextValue = { @@ -540,6 +541,7 @@ export default function ReverseProxiesProvider({ isSelfHostedCluster, }} > + {children} {modalOpen && ( ) { - const { data: users, mutate, isLoading } = useFetchApi( - "/users?service_user=false", - ); - const { data: serviceUsers, mutate: mutateServiceUsers, isLoading: isLoadingServiceUsers } = useFetchApi< - User[] - >("/users?service_user=true"); + const { + data: users, + mutate, + isLoading, + } = useFetchApi("/users?service_user=false"); + const { + data: serviceUsers, + mutate: mutateServiceUsers, + isLoading: isLoadingServiceUsers, + } = useFetchApi("/users?service_user=true"); const refresh = () => { mutate().then(); diff --git a/src/hooks/useIsLicensed.tsx b/src/hooks/useIsLicensed.tsx new file mode 100644 index 0000000..503ebb4 --- /dev/null +++ b/src/hooks/useIsLicensed.tsx @@ -0,0 +1,46 @@ +import useFetchApi from "@utils/api"; +import { hasLicensedFlag, testEditionOverride } from "@utils/netbird"; + +/** + * Endpoint that is only served by licensed management servers. + * Open-source management returns 404 for it. + */ +const LICENSE_PROBE_ENDPOINT = "/integrations/event-streaming"; + +/** + * Error codes that indicate the endpoint exists but the request was not + * authorized, which still proves a licensed management server. + */ +const ENDPOINT_EXISTS_ERROR_CODES = [401, 403, 405]; + +/** + * useIsLicensed determines whether premium features are available on this + * deployment. NetBird Cloud and deployments with NETBIRD_LICENSED=true are + * licensed by definition. For backwards compatibility, deployments without + * the flag are probed once against a licensed-only management endpoint. + */ +export const useIsLicensed = (): { + isLicensed: boolean; + isLoading: boolean; +} => { + const declared = hasLicensedFlag(); + const override = testEditionOverride(); + const { data, error, isLoading } = useFetchApi( + LICENSE_PROBE_ENDPOINT, + true, + false, + !declared && !override, + { shouldRetryOnError: false }, + ); + + if (override) return { isLicensed: override !== "oss", isLoading: false }; + if (declared) return { isLicensed: true, isLoading: false }; + if (isLoading) return { isLicensed: false, isLoading: true }; + if (error) { + return { + isLicensed: ENDPOINT_EXISTS_ERROR_CODES.includes(error.code), + isLoading: false, + }; + } + return { isLicensed: data !== undefined, isLoading: false }; +}; diff --git a/src/hooks/useLocalStorage.tsx b/src/hooks/useLocalStorage.tsx index 3f2e9f6..99afe2d 100644 --- a/src/hooks/useLocalStorage.tsx +++ b/src/hooks/useLocalStorage.tsx @@ -73,8 +73,12 @@ export function useLocalStorage( // Allow value to be a function, so we have the same API as useState const newValue = value instanceof Function ? value(storedValue) : value; - // Save to local storage - window.localStorage.setItem(key, JSON.stringify(newValue)); + // Remove the key when passing null or undefined + if (newValue === null || newValue === undefined) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(newValue)); + } // Save state setStoredValue(newValue); diff --git a/src/hooks/useUrlTab.ts b/src/hooks/useUrlTab.ts index 12d108d..1948e36 100644 --- a/src/hooks/useUrlTab.ts +++ b/src/hooks/useUrlTab.ts @@ -1,3 +1,4 @@ + import { useRouter, useSearchParams } from "next/navigation"; import { useCallback, useMemo } from "react"; @@ -31,4 +32,4 @@ export default function useUrlTab( ); return [tab, setTab]; -} +} \ No newline at end of file diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index b76b09b..82804e5 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -8,6 +8,9 @@ export interface Account { extra: { peer_approval_enabled: boolean; user_approval_required: boolean; + network_traffic_logs_enabled: boolean; + network_traffic_packet_counter_enabled: boolean; + network_traffic_logs_groups: string[]; }; peer_login_expiration_enabled: boolean; peer_expose_enabled?: boolean; diff --git a/src/interfaces/AccountUsageStats.ts b/src/interfaces/AccountUsageStats.ts new file mode 100644 index 0000000..36329d2 --- /dev/null +++ b/src/interfaces/AccountUsageStats.ts @@ -0,0 +1,6 @@ +export interface AccountUsageStats { + active_users: number; + total_users: number; + active_peers: number; + total_peers: number; +} \ No newline at end of file diff --git a/src/interfaces/EDR.ts b/src/interfaces/EDR.ts new file mode 100644 index 0000000..53f0e8b --- /dev/null +++ b/src/interfaces/EDR.ts @@ -0,0 +1,99 @@ +import { Group } from "@/interfaces/Group"; + +export interface CrowdstrikeIntegration { + client_id: string; + secret: string; + cloud_id: string; + groups: string[] | Group[]; + zta_score_threshold: number; // 0 - 100 + enabled: boolean; +} + +export interface IntuneIntegration { + client_id: string; + secret: string; + tenant_id: string; + last_synced_interval: number; + groups: string[] | Group[]; + enabled: boolean; +} + +export interface SentinelOneIntegration { + api_token: string; + api_url: string; + last_synced_interval: number; + last_synced_at?: string; + groups: string[] | Group[]; + match_attributes: SentinelOneMatchAttributes; + enabled: boolean; +} + +export interface SentinelOneMatchAttributes { + active_threats?: number; + encrypted_applications?: boolean; + firewall_enabled?: boolean; + infected?: boolean; + is_active?: boolean; + is_up_to_date?: boolean; + network_status?: string; // "connected" + operational_state?: string; // "na" +} + +export const DEFAULT_SENTINELONE_MATCH_ATTRIBUTES = { + active_threats: 0, + encrypted_applications: true, + firewall_enabled: true, + infected: false, + is_active: true, + is_up_to_date: true, + network_status: "connected", + operational_state: "na", +} as SentinelOneMatchAttributes; + +export interface HuntressIntegration { + api_key: string; + api_secret: string; + last_synced_interval: number; + last_synced_at?: string; + groups: string[] | Group[]; + match_attributes: HuntressMatchAttributes; + enabled: boolean; +} + +export interface HuntressMatchAttributes { + defender_policy_status: string; + defender_status: string; + defender_substatus: string; + firewall_status: string; +} + +export const DEFAULT_HUNTRESS_MATCH_ATTRIBUTES = { + defender_policy_status: "Compliant", + defender_status: "Protected", + defender_substatus: "Up to date", + firewall_status: "Enabled", +} as HuntressMatchAttributes; + +export interface FleetDMIntegration { + api_url: string; + api_token: string; + last_synced_interval: number; + last_synced_at?: string; + groups: string[] | Group[]; + match_attributes: FleetDMMatchAttributes; + enabled: boolean; +} + +export interface FleetDMMatchAttributes { + disk_encryption_enabled?: boolean; + failing_policies_count_max?: number; + vulnerable_software_count_max?: number; + status_online?: boolean; + required_policies?: number[]; +} + +export const DEFAULT_FLEETDM_MATCH_ATTRIBUTES = { + disk_encryption_enabled: true, + failing_policies_count_max: 0, + status_online: true, +} as FleetDMMatchAttributes; diff --git a/src/interfaces/EventStream.ts b/src/interfaces/EventStream.ts index 09a0ec6..223be73 100644 --- a/src/interfaces/EventStream.ts +++ b/src/interfaces/EventStream.ts @@ -6,7 +6,17 @@ export interface EventStream { created_at: Date; updated_at: Date; config: { + url?: string; + headers?: string; + body_template?: string; api_key: string; api_url: string; }; } + +export enum AuthType { + None = "none", + Basic = "basic", + Bearer = "bearer", + Custom = "custom", +} diff --git a/src/interfaces/FirewallGPT.ts b/src/interfaces/FirewallGPT.ts new file mode 100644 index 0000000..24101d6 --- /dev/null +++ b/src/interfaces/FirewallGPT.ts @@ -0,0 +1,33 @@ +export interface FirewallGPTRequest { + prompt: string; +} + +export interface FirewallGPTResponse { + request_id: string; + clarifying_questions: string[]; + prompt_result: PromptResult[]; +} + +export interface PromptResult { + body: any; + execution_index: number; + operation: OperationType; + used_as: string; +} + +export interface FirewallGPTConfirmation { + request_id: string; + confirmation: "yes" | "no"; +} + +export enum OperationType { + CREATE_POLICY = "create_policy", + CREATE_GROUP = "create_group", + CREATE_POSTURE_CHECK = "create_posture_check", + USE_POSTURE_CHECK = "use_posture_check", +} + +export interface RegistrationStatus { + status: "approved" | "pending" | "missing"; + account_id: string; +} diff --git a/src/interfaces/IdentityProvider.ts b/src/interfaces/IdentityProvider.ts index 57ed383..4da1726 100644 --- a/src/interfaces/IdentityProvider.ts +++ b/src/interfaces/IdentityProvider.ts @@ -29,12 +29,60 @@ export interface OktaIntegration { connector_id?: string; } +export interface ScimIntegration { + id: string; + provider: IdentityProvider; + enabled: boolean; + group_prefixes: string[]; + user_group_prefixes: string[]; + auth_token: string; + last_synced_at?: Date; + connector_id?: string; +} + export interface IdentityProviderLog { id: number; level: string; timestamp: Date; } +export interface EnterpriseConnection { + id: string; + enabled: boolean; + name: string; + strategy: string; + discovery_domain: string; + client_id: string; + scopes: string[]; + domains: EnterpriseConnectionDomain[]; +} + +export interface EnterpriseConnectionDomain { + name: string; + validation_token: string; + validation_status: DomainValidationStatus; + validation_last_updated: Date; +} + +export enum DomainValidationStatus { + PENDING = "pending", + VERIFIED = "verified", + FAILED = "failed", +} + +export interface SSOConnection { + id: string; + strategy: string; + provider: string; + name: string; +} + +export enum IdentityProvider { + GENERIC = "generic", + JUMPCLOUD = "jumpcloud", + ENTRA = "entra", +} + export type SSOIdentityProviderType = | "oidc" | "zitadel" diff --git a/src/interfaces/NotificationChannel.ts b/src/interfaces/NotificationChannel.ts new file mode 100644 index 0000000..fb67c05 --- /dev/null +++ b/src/interfaces/NotificationChannel.ts @@ -0,0 +1,48 @@ +export enum NotificationChannelType { + Email = "email", + Webhook = "webhook", + Slack = "slack", +} + +export interface NotificationChannel { + id?: string; + type?: NotificationChannelType; + target?: NotificationEmailChannel | NotificationWebhookChannel; + enabled: boolean; + event_types: NotificationEventType[]; +} + +export interface NotificationEmailChannel { + emails: string[]; +} + +export interface NotificationWebhookChannel { + url: string; + headers?: { [key: string]: string }; +} + +export interface NotificationEventTypeMap { + [key: string]: string; +} + +export enum NotificationEventType { + PeerPendingApproval = "peer.pending.approval", + PeerAdd = "peer.add", + RoutingPeerDisconnect = "routing.peer.disconnect", + RoutingPeerDelete = "routing.peer.delete", + UserPendingApproval = "user.pending.approval", + UserJoin = "user.join", + ServiceUserCreate = "service.user.create", + IdpSyncTokenExpire = "idp.sync.token.expire", + EdrSyncTokenExpire = "edr.sync.token.expire", +} + +export const ALL_NOTIFICATION_EVENT_TYPES = Object.values( + NotificationEventType, +); + +export const NOTIFICATION_CHANNELS_DOCS_LINK = + "https://docs.netbird.io/manage/settings/notifications"; + +export const NOTIFICATION_CHANNELS_WEBHOOK_DOCS_LINK = + "https://docs.netbird.io/manage/settings/notifications#webhook-notifications"; diff --git a/src/interfaces/Peer.ts b/src/interfaces/Peer.ts index 537d106..fe1e567 100644 --- a/src/interfaces/Peer.ts +++ b/src/interfaces/Peer.ts @@ -26,6 +26,7 @@ export interface Peer { inactivity_expiration_enabled: boolean; approval_required: boolean; disapproval_reason?: string; + force_approved?: boolean; city_name: string; country_code: string; connection_ip: string; diff --git a/src/interfaces/Plan.ts b/src/interfaces/Plan.ts new file mode 100644 index 0000000..35b187e --- /dev/null +++ b/src/interfaces/Plan.ts @@ -0,0 +1,19 @@ +export interface Plan { + name: string; + description: string; + features: string[]; + prices: Price[]; + free: boolean; +} + +export interface Price { + currency: Currency; + price: number; + price_id: string; + unit: string; +} + +export enum Currency { + USD = "usd", + EUR = "eur", +} diff --git a/src/interfaces/Subscription.ts b/src/interfaces/Subscription.ts new file mode 100644 index 0000000..9548418 --- /dev/null +++ b/src/interfaces/Subscription.ts @@ -0,0 +1,31 @@ +import { PlanFeatures } from "@/cloud/cloud-hooks/useIsFeatureLocked"; +import { Currency } from "@/interfaces/Plan"; + +export interface Subscription { + active: boolean; + plan_tier: PlanTier; + price_id: string; + price?: number; + currency: Currency; + updated_at: Date; + remaining_trial?: number; // In seconds + features?: PlanFeatures[]; + provider?: string; +} + +export interface Portal { + url: string; +} + +export interface Checkout { + url: string; +} + +export enum PlanTier { + UNKNOWN = "", + FREE = "free", + TEAM = "team", + BUSINESS = "business", + ENTERPRISE = "enterprise", + TRIAL = "trial", +} diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index 31d82d6..ec4fbd0 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -11,6 +11,7 @@ import localFont from "next/font/local"; import React, { Suspense } from "react"; import { Toaster } from "sonner"; import OIDCProvider from "@/auth/OIDCProvider"; +import { useAWSMarketplace } from "@/cloud/aws/useAWSMarketplace"; import FullScreenLoading from "@/components/ui/FullScreenLoading"; import AnalyticsProvider, { GoogleTagManagerHeadScript, @@ -38,6 +39,8 @@ export const viewport: Viewport = { export default function AppLayout({ children, }: Readonly<{ children: React.ReactNode }>) { + useAWSMarketplace(); + return ( diff --git a/src/layouts/DashboardLayout.tsx b/src/layouts/DashboardLayout.tsx index e9c95d9..39d7aca 100644 --- a/src/layouts/DashboardLayout.tsx +++ b/src/layouts/DashboardLayout.tsx @@ -5,24 +5,28 @@ import { useOidcUser } from "@axa-fr/react-oidc"; import Button from "@components/Button"; import { UserAvatar } from "@components/ui/UserAvatar"; import { cn } from "@utils/helpers"; -import { isNetBirdHosted } from "@utils/netbird"; import { useIsSm, useIsXs } from "@utils/responsive"; import { AnimatePresence, motion } from "framer-motion"; import { XIcon } from "lucide-react"; import React from "react"; +import { NetBirdCloudProvider } from "@/cloud/contexts/NetBirdCloudProvider"; +import DistributorProvider from "@/cloud/distributor/contexts/DistributorProvider"; +import MSPProvider from "@/cloud/msp/contexts/MSPProvider"; import AnnouncementProvider, { useAnnouncement, } from "@/contexts/AnnouncementProvider"; import ApplicationProvider, { useApplicationContext, } from "@/contexts/ApplicationProvider"; +import BillingProvider from "@/contexts/BillingProvider"; import CountryProvider from "@/contexts/CountryProvider"; import GroupsProvider from "@/contexts/GroupsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import UsersProvider from "@/contexts/UsersProvider"; import Navigation from "@/layouts/Navigation"; -import { OnboardingProvider } from "@/modules/onboarding/OnboardingProvider"; import Header, { headerHeight } from "./Header"; +import { OnboardingProvider } from "@/modules/onboarding/OnboardingProvider"; +import { isNetBirdCloud } from "@utils/netbird"; export default function DashboardLayout({ children, @@ -31,16 +35,23 @@ export default function DashboardLayout({ }>) { return ( - - - - - {!isNetBirdHosted() && } - {children} - - - - + + + + + + + + + {!isNetBirdCloud() && } + {children} + + + + + + + ); } diff --git a/src/layouts/Header.tsx b/src/layouts/Header.tsx index e21efdb..8439c63 100644 --- a/src/layouts/Header.tsx +++ b/src/layouts/Header.tsx @@ -9,6 +9,9 @@ import { cn } from "@utils/helpers"; import { MenuIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import React from "react"; +import { DistributorTransferAccountModal } from "@/cloud/distributor/DistributorTransferAccountModal"; +import { MSPTenantsSwitcher } from "@/cloud/msp/MSPTenantsSwitcher"; +import { MSPTransferAccountModal } from "@/cloud/msp/MSPTransferAccountModal"; import { useAnnouncement } from "@/contexts/AnnouncementProvider"; import { useApplicationContext } from "@/contexts/ApplicationProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; @@ -65,6 +68,9 @@ export default function NavbarWithDropdown() {
    + + + @@ -88,6 +94,7 @@ const ToggleCollapsableNavigationButton = () => { !isRestricted && (
    +
    @@ -279,7 +302,8 @@ const ActivityNavigationItem = () => { return ( } - label={t('activity')} +label={t('activity')} + href={"/events"} collapsible visible={permission.events.read} > @@ -290,6 +314,13 @@ const ActivityNavigationItem = () => { exactPathMatch={true} visible={permission.events.read} /> + ); }; diff --git a/src/modules/access-control/ssh/SSHAccessType.tsx b/src/modules/access-control/ssh/SSHAccessType.tsx index 8feb7ad..67c9f91 100644 --- a/src/modules/access-control/ssh/SSHAccessType.tsx +++ b/src/modules/access-control/ssh/SSHAccessType.tsx @@ -27,7 +27,7 @@ export const SSHAccessType = ({ value, onChange }: Props) => {
    {value === "full" ? ( @@ -37,7 +37,7 @@ export const SSHAccessType = ({ value, onChange }: Props) => {
    - + Full Access diff --git a/src/modules/access-control/ssh/SSHUsernameSelector.tsx b/src/modules/access-control/ssh/SSHUsernameSelector.tsx index eaa5cdc..3b601e7 100644 --- a/src/modules/access-control/ssh/SSHUsernameSelector.tsx +++ b/src/modules/access-control/ssh/SSHUsernameSelector.tsx @@ -76,7 +76,7 @@ export function SSHUsernameSelector({ "border border-neutral-200 dark:border-nb-gray-700 justify-between py-1.5 px-2.5", "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", )} - data-cy={"ssh-username-selector"} + data-testid={"ssh-username-selector"} disabled={disabled} ref={inputRef} > @@ -143,7 +143,7 @@ export function SSHUsernameSelector({ "bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0", "dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10", )} - data-cy={"ssh-username-input"} + data-testid={"ssh-username-input"} ref={searchRef} value={search} onValueChange={setSearch} diff --git a/src/modules/access-control/table/AccessControlNameCell.tsx b/src/modules/access-control/table/AccessControlNameCell.tsx index 3cc7774..6a7d079 100644 --- a/src/modules/access-control/table/AccessControlNameCell.tsx +++ b/src/modules/access-control/table/AccessControlNameCell.tsx @@ -13,7 +13,7 @@ export default function AccessControlNameCell({ policy }: Readonly) { active={policy.enabled} inactiveDot={"gray"} text={policy.name} - dataCy={policy.name} + data-testid={policy.name} > diff --git a/src/modules/access-control/table/AccessControlTable.tsx b/src/modules/access-control/table/AccessControlTable.tsx index 08f233b..0b668d4 100644 --- a/src/modules/access-control/table/AccessControlTable.tsx +++ b/src/modules/access-control/table/AccessControlTable.tsx @@ -8,35 +8,34 @@ import DataTableHeader from "@components/table/DataTableHeader"; import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; import DataTableResetFilterButton from "@components/table/DataTableResetFilterButton"; import { - CheckboxListPicker, - CheckboxOption, - formatCheckboxChip, + CheckboxListPicker, + CheckboxOption, + formatCheckboxChip, } from "@components/table/filters/CheckboxListPicker"; import { - formatGroupsChip, - GroupsPicker, + formatGroupsChip, + GroupsPicker, } from "@components/table/filters/GroupsPicker"; import { - formatRadioChip, - RadioOption, - RadioPicker, + formatRadioChip, + RadioOption, + RadioPicker, } from "@components/table/filters/RadioPicker"; import { - formatTextChip, - TextInputPicker, + formatTextChip, + TextInputPicker, } from "@components/table/filters/TextInputPicker"; import { - TableFilterChips, - TableFilterDef, - TableFiltersButton, + TableFilterChips, + TableFilterDef, + TableFiltersButton, } from "@components/table/TableFilters"; import GetStartedTest from "@components/ui/GetStartedTest"; import type { ColumnDef, SortingState } from "@tanstack/react-table"; import { removeAllSpaces } from "@utils/helpers"; import { ClockFadingIcon, ExternalLinkIcon, PlusCircle } from "lucide-react"; -import { useTranslations } from "next-intl"; import { usePathname, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useSWRConfig } from "swr"; import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import NoResults from "@/components/ui/NoResults"; @@ -44,7 +43,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLocalStorage } from "@/hooks/useLocalStorage"; import type { Policy } from "@/interfaces/Policy"; import AccessControlModal, { - AccessControlUpdateModal, + AccessControlUpdateModal, } from "@/modules/access-control/AccessControlModal"; import AccessControlActionCell from "@/modules/access-control/table/AccessControlActionCell"; import AccessControlDestinationsCell from "@/modules/access-control/table/AccessControlDestinationsCell"; @@ -52,622 +51,612 @@ import AccessControlDirectionCell from "@/modules/access-control/table/AccessCon import AccessControlNameCell from "@/modules/access-control/table/AccessControlNameCell"; import AccessControlProtoPortsCell from "@/modules/access-control/table/AccessControlProtoPortsCell"; import AccessControlSourcesCell from "@/modules/access-control/table/AccessControlSourcesCell"; +import { FirewallGPTButton } from "@/modules/firewall-gpt/FirewallGPTButton"; +import { FirewallGPTModal } from "@/modules/firewall-gpt/FirewallGPTModal"; type Props = { - policies?: Policy[]; - isLoading: boolean; - headingTarget?: HTMLHeadingElement | null; - isGroupPage?: boolean; + policies?: Policy[]; + isLoading: boolean; + headingTarget?: HTMLHeadingElement | null; + isGroupPage?: boolean; }; +export const AccessControlTableColumns: ColumnDef[] = [ + { + id: "name", + accessorFn: (row) => removeAllSpaces(row?.name), + header: ({ column }) => { + return Name; + }, + sortingFn: "text", + filterFn: "fuzzy", + cell: ({ cell }) => , + }, + { + id: "description", + accessorFn: (row) => removeAllSpaces(row?.description), + sortingFn: "text", + filterFn: "fuzzy", + }, + { + id: "enabled", + accessorKey: "enabled", + accessorFn: (row) => row.enabled, + sortingFn: "basic", + }, + { + id: "sources", + accessorFn: (row) => { + try { + return row.rules[0].sources?.length || 0; + } catch (e) { + console.log(e); + } + return 0; + }, + sortingFn: "basic", + header: ({ column }) => { + return Sources; + }, + cell: ({ cell }) => , + }, + { + id: "direction", + accessorFn: (row) => { + try { + return row.rules[0].bidirectional || true; + } catch (e) { + console.log(e); + } + return 0; + }, + sortingFn: "basic", + header: ({ column }) => { + return Direction; + }, + cell: ({ cell }) => ( + + ), + }, + { + id: "destinations", + accessorFn: (row) => { + try { + return row.rules[0].destinations?.length || 0; + } catch (e) { + console.log(e); + } + return 0; + }, + sortingFn: "basic", + header: ({ column }) => { + return Destinations; + }, + cell: ({ cell }) => ( + + ), + }, + + { + id: "proto_ports", + accessorFn: (row) => row.rules?.[0]?.protocol || "", + sortingFn: "text", + header: ({ column }) => { + return Proto & Ports; + }, + cell: ({ cell }) => ( + + ), + }, + { + id: "id", + accessorKey: "id", + filterFn: "exactMatch", + }, + // Hidden filter columns powering the consolidated Filters UI. + { + id: "source_group_names", + accessorFn: (row) => { + const sources = row.rules?.[0]?.sources; + if (!sources) return []; + return (sources as { name?: string }[]) + .map((s) => s?.name) + .filter((n): n is string => !!n); + }, + filterFn: "arrIncludesSome", + }, + { + id: "destination_group_names", + accessorFn: (row) => { + const destinations = row.rules?.[0]?.destinations; + if (!destinations) return []; + return (destinations as { name?: string }[]) + .map((d) => d?.name) + .filter((n): n is string => !!n); + }, + filterFn: "arrIncludesSome", + }, + { + id: "protocol_filter", + accessorFn: (row) => [row.rules?.[0]?.protocol || "all"], + filterFn: "arrIncludesSome", + }, + { + id: "ports_filter", + accessorFn: (row) => { + const rule = row.rules?.[0]; + const ports = rule?.ports || []; + const ranges = (rule?.port_ranges || []).map( + (r) => `${r.start}-${r.end}`, + ); + return [...ports, ...ranges].join(" "); + }, + filterFn: "includesString", + }, + { + id: "has_posture_checks", + accessorFn: (row) => + (row.source_posture_checks?.length ?? 0) > 0 ? "with" : "without", + filterFn: "equalsString", + }, + { + id: "direction_filter", + accessorFn: (row) => !!row.rules?.[0]?.bidirectional, + }, + { + id: "actions", + accessorKey: "id", + header: "", + cell: ({ cell }) => , + }, +]; + export default function AccessControlTable({ - policies, - isLoading, - headingTarget, - isGroupPage, + policies, + isLoading, + headingTarget, + isGroupPage, }: Readonly) { - const t = useTranslations("policies"); - const tCommon = useTranslations("common"); - const tTable = useTranslations("table"); - const { mutate } = useSWRConfig(); - const path = usePathname(); - const { permission } = usePermissions(); - const params = useSearchParams(); - const idParam = !isGroupPage ? params.get("id") : undefined; + const { mutate } = useSWRConfig(); + const path = usePathname(); + const { permission } = usePermissions(); + const params = useSearchParams(); + const idParam = !isGroupPage ? params.get("id") : undefined; - // Default sorting state of the table - const [sorting, setSorting] = useLocalStorage( - "netbird-table-sort" + path, - [ - { - id: "name", - desc: true, - }, - ], - !isGroupPage, - ); + // Default sorting state of the table + const [sorting, setSorting] = useLocalStorage( + "netbird-table-sort" + path, + [ + { + id: "name", + desc: true, + }, + ], + !isGroupPage, + ); - const [editModal, setEditModal] = useState(false); - const [currentRow, setCurrentRow] = useState(); - const [currentCellClicked, setCurrentCellClicked] = useState(""); + const [editModal, setEditModal] = useState(false); + const [currentRow, setCurrentRow] = useState(); + const [currentCellClicked, setCurrentCellClicked] = useState(""); - const [showTemporaryPolicies, setShowTemporaryPolicies] = useState(false); + const [firewallGPTOpen, setFirewallGPTOpen] = useState(false); - const withTemporaryPolicies = useCallback( - (condition: boolean) => - policies?.filter((policy) => - condition - ? policy?.name?.startsWith("Temporary") && - policy?.name?.endsWith("client") && - policy?.description?.startsWith("Temporary") && - policy?.description?.endsWith("client") - : !( - policy?.name?.startsWith("Temporary") && - policy?.name?.endsWith("client") && - policy?.description?.startsWith("Temporary") && - policy?.description?.endsWith("client") - ), - ) ?? [], - [policies], - ); + const [showTemporaryPolicies, setShowTemporaryPolicies] = useState(false); - const tempPolicies = useMemo( - () => withTemporaryPolicies(true), - [withTemporaryPolicies], - ); - const regularPolicies = useMemo( - () => withTemporaryPolicies(false), - [withTemporaryPolicies], - ); + const withTemporaryPolicies = useCallback( + (condition: boolean) => + policies?.filter((policy) => + condition + ? policy?.name?.startsWith("Temporary") && + policy?.name?.endsWith("client") && + policy?.description?.startsWith("Temporary") && + policy?.description?.endsWith("client") + : !( + policy?.name?.startsWith("Temporary") && + policy?.name?.endsWith("client") && + policy?.description?.startsWith("Temporary") && + policy?.description?.endsWith("client") + ), + ) ?? [], + [policies], + ); - useEffect(() => { - if (showTemporaryPolicies && tempPolicies?.length === 0) { - setShowTemporaryPolicies(false); - } - }, [showTemporaryPolicies, tempPolicies]); + const tempPolicies = useMemo( + () => withTemporaryPolicies(true), + [withTemporaryPolicies], + ); + const regularPolicies = useMemo( + () => withTemporaryPolicies(false), + [withTemporaryPolicies], + ); - // Single-radio status filter mirroring the previous All / Active / - // Inactive ButtonGroup. Routed through the consolidated Filters UI. - const statusOptions = useMemo[]>( - () => [ - { value: undefined, label: tCommon("all"), dotClass: "bg-nb-gray-500" }, - { value: true, label: tCommon("enabled"), dotClass: "bg-green-500" }, - { value: false, label: tCommon("disabled"), dotClass: "bg-nb-gray-700" }, - ], - [tCommon], - ); + useEffect(() => { + if (showTemporaryPolicies && tempPolicies?.length === 0) { + setShowTemporaryPolicies(false); + } + }, [showTemporaryPolicies, tempPolicies]); - const protocolOptions = useMemo[]>( - () => [ - { value: "tcp", label: t("tcp") }, - { value: "udp", label: t("udp") }, - { value: "icmp", label: t("icmp") }, - { value: "netbird-ssh", label: t("netbirdSsh") }, - ], - [t], - ); + // Single-radio status filter mirroring the previous All / Active / + // Inactive ButtonGroup. Routed through the consolidated Filters UI. + const statusOptions = useMemo[]>( + () => [ + { value: undefined, label: "All", dotClass: "bg-nb-gray-500" }, + { value: true, label: "Enabled", dotClass: "bg-green-500" }, + { value: false, label: "Disabled", dotClass: "bg-nb-gray-700" }, + ], + [], + ); - const postureOptions = useMemo[]>( - () => [ - { value: undefined, label: tCommon("all") }, - { value: "with", label: tCommon("enabled") }, - { value: "without", label: tCommon("disabled") }, - ], - [tCommon], - ); + const protocolOptions = useMemo[]>( + () => [ + { value: "tcp", label: "TCP" }, + { value: "udp", label: "UDP" }, + { value: "icmp", label: "ICMP" }, + { value: "netbird-ssh", label: "NetBird SSH" }, + ], + [], + ); - const directionOptions = useMemo[]>( - () => [ - { value: undefined, label: tCommon("all") }, - { value: true, label: t("bidirectional") }, - { value: false, label: t("oneWay") }, - ], - [t, tCommon], - ); + const postureOptions = useMemo[]>( + () => [ + { value: undefined, label: "All" }, + { value: "with", label: "With" }, + { value: "without", label: "Without" }, + ], + [], + ); - // Groups derived from the current policies' sources + destinations, - // so the Sources/Destinations filters only offer groups that actually - // appear in the table. - const tableGroups = useMemo(() => { - if (!policies) return []; - const map = new Map(); - for (const policy of policies) { - const rule = policy.rules?.[0]; - if (!rule) continue; - const both = [ - ...((rule.sources as { id?: string; name?: string }[] | null) ?? []), - ...((rule.destinations as { id?: string; name?: string }[] | null) ?? - []), - ]; - for (const g of both) { - if (g?.name && !map.has(g.name)) { - map.set(g.name, { id: g.id, name: g.name }); - } - } - } - return Array.from(map.values()); - }, [policies]); + const directionOptions = useMemo[]>( + () => [ + { value: undefined, label: "All" }, + { value: true, label: "Bidirectional" }, + { value: false, label: "One-way" }, + ], + [], + ); - const filterDefs = useMemo( - () => [ - { - id: "enabled", - label: t("status"), - renderPicker: (p) => ( - - ), - formatChip: (v) => - formatRadioChip(v as boolean | undefined, statusOptions), - }, - { - id: "source_group_names", - label: t("sources"), - renderPicker: (p) => ( - - ), - formatChip: (v) => formatGroupsChip(v as string[] | undefined), - }, - { - id: "destination_group_names", - label: t("destinations"), - renderPicker: (p) => ( - - ), - formatChip: (v) => formatGroupsChip(v as string[] | undefined), - }, - { - id: "direction_filter", - label: t("direction"), - renderPicker: (p) => ( - - ), - formatChip: (v) => - formatRadioChip(v as boolean | undefined, directionOptions), - }, - { - id: "protocol_filter", - label: t("protocol"), - renderPicker: (p) => ( - - ), - formatChip: (v) => - formatCheckboxChip( - v as string[] | undefined, - protocolOptions, - t("protocols"), - ), - }, - { - id: "ports_filter", - label: t("port"), - renderPicker: (p) => ( - - ), - formatChip: (v) => formatTextChip(v as string | undefined), - }, - { - id: "has_posture_checks", - label: t("postureChecks"), - renderPicker: (p) => ( - - ), - formatChip: (v) => - formatRadioChip(v as string | undefined, postureOptions), - }, - ], - [ - statusOptions, - protocolOptions, - postureOptions, - directionOptions, - tableGroups, - t, - tCommon, - tTable, - ], - ); + // Groups derived from the current policies' sources + destinations, + // so the Sources/Destinations filters only offer groups that actually + // appear in the table. + const tableGroups = useMemo(() => { + if (!policies) return []; + const map = new Map(); + for (const policy of policies) { + const rule = policy.rules?.[0]; + if (!rule) continue; + const both = [ + ...((rule.sources as { id?: string; name?: string }[] | null) ?? []), + ...((rule.destinations as { id?: string; name?: string }[] | null) ?? + []), + ]; + for (const g of both) { + if (g?.name && !map.has(g.name)) { + map.set(g.name, { id: g.id, name: g.name }); + } + } + } + return Array.from(map.values()); + }, [policies]); - const columns = useMemo[]>( - () => [ - { - id: "name", - accessorFn: (row) => removeAllSpaces(row?.name), - header: ({ column }) => { - return {t("name")}; - }, - sortingFn: "text", - filterFn: "fuzzy", - cell: ({ cell }) => ( - - ), - }, - { - id: "description", - accessorFn: (row) => removeAllSpaces(row?.description), - sortingFn: "text", - filterFn: "fuzzy", - }, - { - id: "enabled", - accessorKey: "enabled", - accessorFn: (row) => row.enabled, - sortingFn: "basic", - }, - { - id: "sources", - accessorFn: (row) => { - try { - return row.rules[0].sources?.length || 0; - } catch (e) { - console.log(e); - } - return 0; - }, - sortingFn: "basic", - header: ({ column }) => { - return ( - {t("sources")} - ); - }, - cell: ({ cell }) => ( - - ), - }, - { - id: "direction", - accessorFn: (row) => { - try { - return row.rules[0].bidirectional ?? true; - } catch (e) { - console.log(e); - } - return 0; - }, - sortingFn: "basic", - header: ({ column }) => { - return ( - {t("direction")} - ); - }, - cell: ({ cell }) => ( - - ), - }, - { - id: "destinations", - accessorFn: (row) => { - try { - return row.rules[0].destinations?.length || 0; - } catch (e) { - console.log(e); - } - return 0; - }, - sortingFn: "basic", - header: ({ column }) => { - return ( - - {t("destinations")} - - ); - }, - cell: ({ cell }) => ( - - ), - }, + const filterDefs = useMemo( + () => [ + { + id: "enabled", + label: "Status", + renderPicker: (p) => ( + + ), + formatChip: (v) => + formatRadioChip(v as boolean | undefined, statusOptions), + }, + { + id: "source_group_names", + label: "Sources", + renderPicker: (p) => ( + + ), + formatChip: (v) => formatGroupsChip(v as string[] | undefined), + }, + { + id: "destination_group_names", + label: "Destinations", + renderPicker: (p) => ( + + ), + formatChip: (v) => formatGroupsChip(v as string[] | undefined), + }, + { + id: "direction_filter", + label: "Direction", + renderPicker: (p) => ( + + ), + formatChip: (v) => + formatRadioChip(v as boolean | undefined, directionOptions), + }, + { + id: "protocol_filter", + label: "Protocol", + renderPicker: (p) => ( + + ), + formatChip: (v) => + formatCheckboxChip( + v as string[] | undefined, + protocolOptions, + "protocols", + ), + }, + { + id: "ports_filter", + label: "Port", + renderPicker: (p) => ( + + ), + formatChip: (v) => formatTextChip(v as string | undefined), + }, + { + id: "has_posture_checks", + label: "Posture Checks", + renderPicker: (p) => ( + + ), + formatChip: (v) => + formatRadioChip(v as string | undefined, postureOptions), + }, + ], + [ + statusOptions, + protocolOptions, + postureOptions, + directionOptions, + tableGroups, + ], + ); - { - id: "proto_ports", - accessorFn: (row) => row.rules?.[0]?.protocol || "", - sortingFn: "text", - header: ({ column }) => { - return ( - {t("protoPorts")} - ); - }, - cell: ({ cell }) => ( - - ), - }, - { - id: "id", - accessorKey: "id", - filterFn: "exactMatch", - }, - // Hidden filter columns powering the consolidated Filters UI. - { - id: "source_group_names", - accessorFn: (row) => { - const sources = row.rules?.[0]?.sources; - if (!sources) return []; - return (sources as { name?: string }[]) - .map((s) => s?.name) - .filter((n): n is string => !!n); - }, - filterFn: "arrIncludesSome", - }, - { - id: "destination_group_names", - accessorFn: (row) => { - const destinations = row.rules?.[0]?.destinations; - if (!destinations) return []; - return (destinations as { name?: string }[]) - .map((d) => d?.name) - .filter((n): n is string => !!n); - }, - filterFn: "arrIncludesSome", - }, - { - id: "protocol_filter", - accessorFn: (row) => [row.rules?.[0]?.protocol || "all"], - filterFn: "arrIncludesSome", - }, - { - id: "ports_filter", - accessorFn: (row) => { - const rule = row.rules?.[0]; - const ports = rule?.ports || []; - const ranges = (rule?.port_ranges || []).map( - (r) => `${r.start}-${r.end}`, - ); - return [...ports, ...ranges].join(" "); - }, - filterFn: "includesString", - }, - { - id: "has_posture_checks", - accessorFn: (row) => - (row.source_posture_checks?.length ?? 0) > 0 ? "with" : "without", - filterFn: "equalsString", - }, - { - id: "direction_filter", - accessorFn: (row) => !!row.rules?.[0]?.bidirectional, - }, - { - id: "actions", - accessorKey: "id", - header: "", - cell: ({ cell }) => ( - - ), - }, - ], - [t], - ); + return ( + <> + - return ( - <> - {editModal && currentRow && ( - - )} - ( - - )} - columnVisibility={{ - description: false, - id: false, - enabled: false, - temporary: false, - source_group_names: false, - destination_group_names: false, - protocol_filter: false, - ports_filter: false, - has_posture_checks: false, - direction_filter: false, - }} - rowClassName={(row) => (row.original.enabled ? "" : "opacity-50")} - data={showTemporaryPolicies ? tempPolicies : regularPolicies} - onRowClick={(row, cell) => { - setCurrentRow(row.original); - setEditModal(true); - setCurrentCellClicked(cell); - }} - searchPlaceholder={t("searchPlaceholder")} - getStartedCard={ - isGroupPage ? ( - - } - > -
    - - - -
    -
    - ) : ( - - } - color={"gray"} - size={"large"} - /> - } - title={t("createNewPolicy")} - description={t("createNewPolicyDescription")} - button={ -
    - - - -
    - } - learnMore={ - <> - {t("learnMoreAbout")} - - {t("accessControls")} - - - - } - /> - ) - } - rightSide={() => ( - <> - {policies && policies?.length > 0 && ( -
    - - - -
    - )} - - )} - > - {(table) => { - return ( - <> - + {editModal && currentRow && ( + + )} + ( + + )} + columnVisibility={{ + description: false, + id: false, + enabled: false, + temporary: false, + source_group_names: false, + destination_group_names: false, + protocol_filter: false, + ports_filter: false, + has_posture_checks: false, + direction_filter: false, + }} + rowClassName={(row) => (row.original.enabled ? "" : "opacity-50")} + data={showTemporaryPolicies ? tempPolicies : regularPolicies} + onRowClick={(row, cell) => { + setCurrentRow(row.original); + setEditModal(true); + setCurrentCellClicked(cell); + }} + searchPlaceholder={"Search by name and description..."} + getStartedCard={ + isGroupPage ? ( + + } + > +
    + + + +
    +
    + ) : ( + + } + color={"gray"} + size={"large"} + /> + } + title={"Create New Policy"} + description={ + "It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports." + } + button={ +
    + setFirewallGPTOpen(true)} /> + + + +
    + } + learnMore={ + <> + Learn more about + + Access Controls + + + + } + /> + ) + } + rightSide={() => ( + <> + {policies && policies?.length > 0 && ( +
    + setFirewallGPTOpen(true)} /> + + + +
    + )} + + )} + > + {(table) => { + return ( + <> + - { - table.setPageIndex(0); - table.resetColumnFilters(); - table.resetGlobalFilter(); - }} - /> + { + table.setPageIndex(0); + table.setColumnFilters([]); + table.setGlobalFilter(""); + }} + /> - {tempPolicies?.length > 0 && ( - - {t("temporaryPoliciesTooltip")} -
    - } - > - - - )} + {tempPolicies?.length > 0 && ( + + Show temporary policies created by the NetBird browser + client. These policies are ephemeral and will be deleted + automatically after a short period of time. +
    + } + > + + + )} - { - mutate("/policies").then(); - mutate("/groups").then(); - }} - /> - - ); - }} - - - ); + { + mutate("/policies").then(); + mutate("/groups").then(); + }} + /> + + ); + }} + + + ); } diff --git a/src/modules/access-tokens/AccessTokenActionCell.tsx b/src/modules/access-tokens/AccessTokenActionCell.tsx index 93d8054..c337a0c 100644 --- a/src/modules/access-tokens/AccessTokenActionCell.tsx +++ b/src/modules/access-tokens/AccessTokenActionCell.tsx @@ -55,7 +55,7 @@ export default function AccessTokenActionCell({ variant={"danger-outline"} size={"sm"} onClick={handleConfirm} - data-cy={"access-token-delete"} + data-testid={"access-token-delete"} > Delete diff --git a/src/modules/access-tokens/CreateAccessTokenModal.tsx b/src/modules/access-tokens/CreateAccessTokenModal.tsx index ca3c889..b4c5437 100644 --- a/src/modules/access-tokens/CreateAccessTokenModal.tsx +++ b/src/modules/access-tokens/CreateAccessTokenModal.tsx @@ -103,7 +103,7 @@ export default function CreateAccessTokenModal({ variant={"secondary"} className={"w-full"} tabIndex={-1} - data-cy={"access-token-copy-close"} + data-testid={"access-token-copy-close"} > Close @@ -180,7 +180,7 @@ export function AccessTokenModalContent({ Set an easily identifiable name for your token setName(e.target.value)} @@ -195,7 +195,7 @@ export function AccessTokenModalContent({ Create Token diff --git a/src/modules/activity/ActivityDescription.tsx b/src/modules/activity/ActivityDescription.tsx index b682108..d4f3b86 100644 --- a/src/modules/activity/ActivityDescription.tsx +++ b/src/modules/activity/ActivityDescription.tsx @@ -645,6 +645,30 @@ export default function ActivityDescription({ event }: Props) {
    ); + if (event.activity_code == "integrated-validator.peer.compliance-bypassed") + return ( +
    + Peer {m?.name} with the NetBird IP {m?.ip}{" "} + compliance bypassed for {m?.platform} integration + {m?.original_reason && ( + <> + {" "} + (original non-compliant reason: {m?.original_reason}) + + )} +
    + ); + + if ( + event.activity_code == "integrated-validator.peer.compliance-bypass-revoked" + ) + return ( +
    + Peer {m?.name} with the NetBird IP {m?.ip}{" "} + compliance bypass revoked for {m?.platform} integration +
    + ); + /** * Resource */ @@ -839,7 +863,7 @@ export default function ActivityDescription({ event }: Props) {
    Service {m.domain} in cluster{" "} {m.proxy_cluster} was updated with authentication{" "} - {m.auth === "true" ? "Enabled" : "Disabled"} + {m.auth ? "Enabled" : "Disabled"}
    ); @@ -851,6 +875,69 @@ export default function ActivityDescription({ event }: Props) {
    ); + /** + * Distributor + */ + + if (event.activity_code == "reseller.msp.created") + return ( +
    + Customer {m.msp_name} with domain{" "} + {m.msp_domain} was created +
    + ); + + if (event.activity_code == "reseller.activated") + return
    Distributor account was activated
    ; + + if (event.activity_code == "reseller.msp.deleted") + return ( +
    + Customer {m.msp_name} with domain{" "} + {m.msp_domain} was deleted +
    + ); + + if (event.activity_code == "reseller.msp.unlinked") + return ( +
    + Customer {m.msp_name} with domain{" "} + {m.msp_domain} was unlinked +
    + ); + + if (event.activity_code == "reseller.msp.invite.requested") + return ( +
    + Invite requested for customer {m.msp_name} with domain{" "} + {m.msp_domain} +
    + ); + + if (event.activity_code == "reseller.msp.invite.accepted") + return ( +
    + Invite accepted by customer {m.msp_name} with domain{" "} + {m.msp_domain} +
    + ); + + if (event.activity_code == "reseller.msp.invite.declined") + return ( +
    + Invite declined by customer {m.msp_name} with domain{" "} + {m.msp_domain} +
    + ); + + if (event.activity_code == "reseller.msp.updated") + return ( +
    + Customer {m.msp_name} with domain{" "} + {m.msp_domain} was updated +
    + ); + return (
    {event.activity} diff --git a/src/modules/activity/ActivityTypeIcon.tsx b/src/modules/activity/ActivityTypeIcon.tsx index 8b6b9a3..e856112 100644 --- a/src/modules/activity/ActivityTypeIcon.tsx +++ b/src/modules/activity/ActivityTypeIcon.tsx @@ -2,6 +2,7 @@ import { cn } from "@utils/helpers"; import { ArrowLeftRight, Blocks, + BoxIcon, Cog, CreditCardIcon, FingerprintIcon, @@ -54,6 +55,7 @@ const ActivityTypeMappings = { resource: Layers3Icon, network: NetworkIcon, identityprovider: FingerprintIcon, + reseller: BoxIcon, service: ReverseProxyIcon, } as const; diff --git a/src/modules/billing/LimitsReachedModal.tsx b/src/modules/billing/LimitsReachedModal.tsx new file mode 100644 index 0000000..1ba3396 --- /dev/null +++ b/src/modules/billing/LimitsReachedModal.tsx @@ -0,0 +1,141 @@ +import Button from "@components/Button"; +import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import { useLocalStorage } from "@hooks/useLocalStorage"; +import dayjs from "dayjs"; +import { MailIcon } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import * as React from "react"; +import { useEffect, useMemo, useState } from "react"; +import NetBirdIcon from "@/assets/icons/NetBirdIcon"; +import { useBilling } from "@/contexts/BillingProvider"; + +export const LimitsReachedModal = () => { + const { isFreePlan, usagePercentage, isTrial, isLoading, currentPlan } = + useBilling(); + + const [firstTimeOpen, setFirstTimeOpen] = useLocalStorage( + "netbird-limits-first-open", + undefined, + ); + + const hasLimitsReached = useMemo(() => { + if (isTrial === undefined) return false; + if (currentPlan === undefined) return false; + if (isLoading) return false; + return isFreePlan && !isTrial && usagePercentage > 100; + }, [isFreePlan, isTrial, usagePercentage, isLoading, currentPlan]); + + useEffect(() => { + if (isTrial) setFirstTimeOpen(undefined); + }, [isTrial, setFirstTimeOpen]); + + return hasLimitsReached && ; +}; + +const LimitReachedContent = () => { + const [open, setOpen] = useState(false); + + const [firstTimeOpen, setFirstTimeOpen] = useLocalStorage( + "netbird-limits-first-open", + undefined, + ); + + const [lastClose, setLastClose] = useLocalStorage( + "netbird-limits-last-close", + undefined, + ); + + const router = useRouter(); + const pathname = usePathname(); + const params = useSearchParams(); + const tab = params.get("tab"); + + const isPlansAndBillingPage = useMemo(() => { + return pathname === "/settings" && tab === "plans-and-billing"; + }, [pathname, tab]); + + const daysSinceFirstOpen = useMemo(() => { + if (!firstTimeOpen) return 0; + return dayjs().diff(dayjs(firstTimeOpen), "day"); + }, [firstTimeOpen]); + + const daysSinceLastClose = useMemo(() => { + if (!lastClose) return 1; + return dayjs().diff(dayjs(lastClose), "day"); + }, [lastClose]); + + const canClose = useMemo(() => { + return daysSinceFirstOpen < 14; + }, [daysSinceFirstOpen]); + + useEffect(() => { + if (!firstTimeOpen) setFirstTimeOpen(dayjs().toDate()); + if (!isPlansAndBillingPage && daysSinceFirstOpen >= 14) { + setOpen(true); + return; + } + if (!isPlansAndBillingPage && daysSinceLastClose >= 1) { + setOpen(true); + return; + } + }, [firstTimeOpen, isPlansAndBillingPage, daysSinceLastClose]); + + const onOpenChange = (open: boolean) => { + if (!canClose) return; + setOpen(open); + setLastClose(dayjs().toDate()); + }; + + const redirectToPlansAndBilling = () => { + setOpen(false); + setLastClose(dayjs().toDate()); + router.push("/settings?tab=plans-and-billing"); + }; + + return ( + + + +
    +
    + +
    + +
    + Subscription Limit Reached +
    +
    + It looks like you’ve hit the limit of your current subscription. + Upgrade now to unlock additional features and increase your limits. +
    +
    + + + + + + +
    +
    + ); +}; diff --git a/src/modules/billing/NavigationUsageInfo.tsx b/src/modules/billing/NavigationUsageInfo.tsx new file mode 100644 index 0000000..e87bd6d --- /dev/null +++ b/src/modules/billing/NavigationUsageInfo.tsx @@ -0,0 +1,179 @@ +import Button from "@components/Button"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { cn } from "@utils/helpers"; +import { isNetBirdCloud } from "@utils/netbird"; +import { MonitorSmartphoneIcon, Users2Icon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import Skeleton from "react-loading-skeleton"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { useAnalytics } from "@/contexts/AnalyticsProvider"; +import { useApplicationContext } from "@/contexts/ApplicationProvider"; +import { useBilling } from "@/contexts/BillingProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { PlanIcon } from "@/modules/billing/PlanIcon"; +import { TrialNavigationInfoCard } from "@/modules/billing/trial/TrialNavigationInfoCard"; + +export const NavigationUsageInfo = () => { + const { permission } = usePermissions(); + const { isNavigationCollapsed, mobileNavOpen } = useApplicationContext(); + + const { isAccountWithMSPParent } = useMSP(); + if (isAccountWithMSPParent) return; + + const canViewBilling = permission?.billing?.update && isNetBirdCloud(); + if (!canViewBilling) return; + + return ( +
    + +
    + ); +}; + +const NavigationUsageInfoContent = () => { + const { + currentPlan: plan, + stats, + isLoading, + maxPeersOfPlan: maxPeers, + isFreePlan, + usagePercentage, + isTrial, + } = useBilling(); + + const router = useRouter(); + const { permission } = usePermissions(); + const { trackEvent } = useAnalytics(); + + if (isLoading) + return ; + + if (isTrial) return ; + + return ( +
    { + permission?.billing?.update && + router.push("/settings?tab=plans-and-billing"); + }} + > + {plan && ( +
    + +
    +
    {plan.name}
    +
    + {plan.description} +
    +
    +
    + )} + + {isFreePlan && ( + <> +
    +
    + +
    + + {stats?.active_users} + + {isFreePlan ? ( + of 5 Users + ) : ( + + {" "} + {stats?.active_peers && stats.active_peers > 1 + ? "Users" + : "User"} + + )} +
    +
    + +
    + +
    + + {stats?.active_peers} + + of {maxPeers} Peers +
    +
    +
    +
    +
    +
    +
    = 100 + ? "bg-red-500" + : "from-netbird to-netbird-500 bg-gradient-to-r", + )} + style={{ + width: `${usagePercentage}%`, + }} + >
    +
    +
    + {isFreePlan && usagePercentage >= 80 && ( +
    = 100 ? "text-red-500" : "text-netbird", + )} + > + + {usagePercentage >= 100 + ? "Usage limit reached" + : "Approaching usage limit"} +
    + )} +
    + + {permission?.billing?.update && ( +
    + +
    + )} + + )} +
    + ); +}; diff --git a/src/modules/billing/PlanCard.tsx b/src/modules/billing/PlanCard.tsx new file mode 100644 index 0000000..de31b79 --- /dev/null +++ b/src/modules/billing/PlanCard.tsx @@ -0,0 +1,220 @@ +import Button from "@components/Button"; +import FullTooltip from "@components/FullTooltip"; +import dayjs from "dayjs"; +import { + Check, + HelpCircle, + Loader2, + MonitorSmartphoneIcon, + UsersIcon, +} from "lucide-react"; +import * as React from "react"; +import { useMemo } from "react"; +import Skeleton from "react-loading-skeleton"; +import { Currency, Plan } from "@/interfaces/Plan"; +import { PlanTier, Subscription } from "@/interfaces/Subscription"; +import { PlanIcon } from "@/modules/billing/PlanIcon"; + +type Props = { + currentPlan?: Plan; + currentSubscription?: Subscription; + plan: Plan; + currency: Currency; + isSubscribing: { + team: boolean; + business: boolean; + }; + onClick: (plan: Plan) => void; + buttonText?: { + upgrade?: string; + downgrade?: string; + }; +}; + +export const PlanCard = ({ + plan, + currency, + currentPlan, + currentSubscription, + isSubscribing = { + team: false, + business: false, + }, + onClick, + buttonText = { + upgrade: "Upgrade to", + downgrade: "Downgrade to", + }, +}: Props) => { + // Price of the plan in the selected currency + const planPrice = + plan.prices?.find((p) => p.currency == currency)?.price || 0; + + // Price of the current plan in the selected currency + const currentPlanPrice = currentPlan?.prices?.find( + (p) => p.currency == currency, + )?.price; + + const isDowngrade = useMemo(() => { + if (!planPrice) return false; + if (!currentPlanPrice) return false; + return planPrice < currentPlanPrice; + }, [planPrice, currentPlanPrice]); + + const canUpgrade = useMemo(() => { + if (currentSubscription && !currentSubscription.active) return true; + if (currentSubscription && !currentSubscription.updated_at) return true; + if (currentSubscription && currentSubscription.plan_tier === PlanTier.FREE) + return true; + if (currentSubscription && currentSubscription.plan_tier === PlanTier.TRIAL) + return true; + const updatedAt = dayjs(currentSubscription?.updated_at); + const now = dayjs(); + const diff = now.diff(updatedAt, "hour"); + return diff >= 48; + }, [currentSubscription]); + + const planButtonVariant = (plan: Plan) => { + if (!canUpgrade) return "input"; + if (plan.name == "Team") return "input"; + if (plan.name === currentPlan?.name) return "input"; + return "primary"; + }; + + const planHelpCircle = (plan: Plan) => { + if (!canUpgrade && plan.name !== currentPlan?.name) + return ; + }; + + const planButtonText = (plan: Plan) => { + if (plan.name === currentPlan?.name) return "Current Plan"; + if (isDowngrade) return `${buttonText?.downgrade} ${plan.name}`; + return `${buttonText?.upgrade} ${plan.name}`; + }; + + const planLoadingIcon = () => { + let isLoading = + plan.name.toLowerCase() === PlanTier.TEAM + ? isSubscribing.team + : isSubscribing.business; + if (isLoading) { + return ( + + + + ); + } + return null; + }; + + return ( +
    +
    + {/* Plan Name */} +
    + +
    +
    + {plan.name} +
    + {plan.description}
    + } + interactive={false} + className={"min-w-0 flex"} + > +
    + {plan.description} +
    + +
    +
    + + {/* Plan Price */} +
    +

    + {currency == Currency.USD && "$"} + {planPrice / 100} + {currency == Currency.EUR && "€"} +

    +
    + per user / month +
    +
    + + {/* Plan Users & Machines */} +
    +
    + + Unlimited Users +
    +
    + + + {" "} + 100 machines + 10 per user + +
    +
    + {/* Plan Features */} + {plan?.features && ( +
      + {plan.features?.map((feature, index) => ( +
    • + + {feature} +
    • + ))} +
    + )} +
    + + {/* Plan Button */} + + Your plan was recently updated. Please wait for 48 hours from the + last update to change your plan again. +
    + } + disabled={canUpgrade || plan.name === currentPlan?.name} + interactive={false} + > + + +
    + ); +}; + +export const PlanLoadingSkeleton = ({ height = 382 }: { height?: number }) => { + return ( + + ); +}; diff --git a/src/modules/billing/PlanCurrentPlan.tsx b/src/modules/billing/PlanCurrentPlan.tsx new file mode 100644 index 0000000..cfe828c --- /dev/null +++ b/src/modules/billing/PlanCurrentPlan.tsx @@ -0,0 +1,202 @@ +import Button from "@components/Button"; +import FullTooltip from "@components/FullTooltip"; +import { cn } from "@utils/helpers"; +import { + CreditCardIcon, + EditIcon, + ExternalLinkIcon, + HelpCircle, + MonitorSmartphoneIcon, + Users2Icon, +} from "lucide-react"; +import Link from "next/link"; +import * as React from "react"; +import { useBilling } from "@/contexts/BillingProvider"; +import { AccountUsageStats } from "@/interfaces/AccountUsageStats"; +import { Currency, Plan, Price } from "@/interfaces/Plan"; +import { PlanIcon } from "@/modules/billing/PlanIcon"; + +type Props = { + currentPlan: Plan; + stats?: AccountUsageStats; + isFreePlan: boolean; + maxPeersOfPlan: number; + estimatedPrice: number; + isTrial?: boolean; + trialDaysRemaining: number; + showManagePlanIfAvailable?: boolean; + currentPlanPrice?: Price; + aws?: boolean; +}; +export const PlanCurrentPlan = ({ + currentPlan, + stats, + showManagePlanIfAvailable = false, + isFreePlan, + maxPeersOfPlan, + estimatedPrice, + isTrial, + trialDaysRemaining, + currentPlanPrice, + aws = false, +}: Props) => { + const { visitCustomerPortal } = useBilling(); + + return ( +
    +
    +
    + + +
    +
    + {isTrial ? "Free Trial" : currentPlan.name} +
    + + {isTrial + ? `Trial ends in ${trialDaysRemaining} days` + : currentPlan.description} +
    + } + disabled={isTrial} + interactive={false} + className={"min-w-0 flex"} + > +
    + {isTrial + ? `Trial ends in ${trialDaysRemaining} days` + : currentPlan.description} +
    + +
    +
    + {!isFreePlan && showManagePlanIfAvailable && ( + <> + {aws ? ( +
    + + + +
    + ) : ( +
    + +
    + )} + + )} +
    +
    + {isTrial && ( +
    + {`You currently have access to NetBird's full set of features & integrations. `} + {currentPlan.name == "Team" && + `Your Team plan remains active during this trial. `} + {`After the trial, you will return to your ${currentPlan.name} plan unless you choose to upgrade.`} +
    + )} +
    +
    + +
    + + {stats?.active_users} + + {isFreePlan && !isTrial ? ( + of 5 Users + ) : ( + + {" "} + {stats?.active_users == 1 ? "User" : "Users"} + + )} +
    +
    + +
    + +
    + + {stats?.active_peers} + + + {isTrial ? ( + + {stats?.active_peers == 1 ? ` Peer` : ` Peers`} + + ) : ( + + {` of ${maxPeersOfPlan} Peers`} + + )} +
    +
    + + {!isFreePlan && !isTrial && ( +
    + +
    + + {currentPlanPrice?.currency == Currency.USD && "$"} + {estimatedPrice} + {currentPlanPrice?.currency == Currency.EUR && "€"} + + per month +
    + + The estimated price is calculated based on the number of + active users and active peers. +
    + } + interactive={false} + > + + +
    + )} +
    +
    +
  • + ); +}; diff --git a/src/modules/billing/PlanIcon.tsx b/src/modules/billing/PlanIcon.tsx new file mode 100644 index 0000000..be57aee --- /dev/null +++ b/src/modules/billing/PlanIcon.tsx @@ -0,0 +1,37 @@ +import { cn } from "@utils/helpers"; +import { BriefcaseIcon, Sparkles, UserIcon, UsersIcon } from "lucide-react"; +import * as React from "react"; + +type Props = { + name: string; + size?: number; +}; +export const PlanIcon = ({ name, size = 40 }: Props) => { + const tier = name.toLowerCase(); + + const isFree = tier.includes("free"); + const isTeam = tier.includes("team"); + const isBusiness = tier.includes("business"); + const isTrial = tier.includes("trial"); + + return ( +
    + {isFree && } + {isTrial && } + {isTeam && } + {isBusiness && } +
    + ); +}; diff --git a/src/modules/billing/PlanSuccessModal.tsx b/src/modules/billing/PlanSuccessModal.tsx new file mode 100644 index 0000000..a55a107 --- /dev/null +++ b/src/modules/billing/PlanSuccessModal.tsx @@ -0,0 +1,43 @@ +import Button from "@components/Button"; +import { Modal, ModalClose, ModalContent } from "@components/modal/Modal"; +import Paragraph from "@components/Paragraph"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import { cn } from "@utils/helpers"; +import { useSearchParams } from "next/navigation"; +import * as React from "react"; +import { useState } from "react"; + +export const PlanSuccessModal = () => { + const params = useSearchParams(); + const subscriptionSuccess = params.get("success"); + const [successModal, setSuccessModal] = useState( + subscriptionSuccess === "true", + ); + return ( + + + + +
    +

    + Thank you for subscribing
    + to NetBird! 🎉 +

    + + Your subscription has been successfully activated. You have now full + access to all NetBird features of your selected plan. + + + + +
    +
    +
    + ); +}; diff --git a/src/modules/billing/PlansAndBillingTab.tsx b/src/modules/billing/PlansAndBillingTab.tsx new file mode 100644 index 0000000..53a8d20 --- /dev/null +++ b/src/modules/billing/PlansAndBillingTab.tsx @@ -0,0 +1,291 @@ +import Breadcrumbs from "@components/Breadcrumbs"; +import InlineLink from "@components/InlineLink"; +import Paragraph from "@components/Paragraph"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/Select"; +import Separator from "@components/Separator"; +import { VerticalTabs } from "@components/VerticalTabs"; +import * as Tabs from "@radix-ui/react-tabs"; +import { isNetBirdCloud } from "@utils/netbird"; +import { + CreditCardIcon, + DollarSignIcon, + EuroIcon, + ExternalLinkIcon, +} from "lucide-react"; +import * as React from "react"; +import { useState } from "react"; +import Skeleton from "react-loading-skeleton"; +import SettingsIcon from "@/assets/icons/SettingsIcon"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { useBilling } from "@/contexts/BillingProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { Currency, Plan } from "@/interfaces/Plan"; +import { PlanTier } from "@/interfaces/Subscription"; +import { PlanCard, PlanLoadingSkeleton } from "@/modules/billing/PlanCard"; +import { PlanCurrentPlan } from "@/modules/billing/PlanCurrentPlan"; +import { PlanSuccessModal } from "@/modules/billing/PlanSuccessModal"; +import { TrialGradientCard } from "@/modules/billing/trial/TrialGradientCard"; + +export const PlansAndBillingTab = () => { + const { permission } = usePermissions(); + + const { isAccountWithMSPParent } = useMSP(); + if (isAccountWithMSPParent) return; + + const canViewBilling = permission?.billing?.update && isNetBirdCloud(); + if (!canViewBilling) return; + + return ; +}; + +export const PlansAndBillingTabTrigger = () => { + const { permission } = usePermissions(); + + const { isAccountWithMSPParent } = useMSP(); + if (isAccountWithMSPParent) return; + + const canViewBilling = permission?.billing?.update && isNetBirdCloud(); + if (!canViewBilling) return; + + return ( + + + Plans & Billing + + ); +}; + +const PlansAndBillingTabContent = () => { + const { + plans, + subscription, + currentPlan, + subscribe, + canUpgrade, + changeSubscription, + isTrialAvailable, + isTrial, + currency, + setCurrency, + currentPlanPrice, + trialDaysRemaining, + estimatedPrice, + isFreePlan, + maxPeersOfPlan, + stats, + isAWS, + } = useBilling(); + + const teamAndBusinessPlans = React.useMemo(() => { + const filteredPlans = plans?.filter( + (plan) => + plan.name.toLowerCase().includes("team") || + plan.name.toLowerCase().includes("business"), + ); + + if ( + !filteredPlans || + !subscription?.active || + !subscription?.price || + subscription.plan_tier === PlanTier.FREE || + subscription.plan_tier === PlanTier.TRIAL + ) { + return filteredPlans; + } + + return filteredPlans.map((plan) => { + const planTier = plan.name.toLowerCase(); + const isCurrentPlan = + planTier === subscription.plan_tier.toLowerCase() && + (planTier === PlanTier.TEAM || planTier === PlanTier.BUSINESS); + + if (!isCurrentPlan) { + return plan; + } + + const updatedPrices = plan.prices.map((price) => { + if (price.currency === subscription.currency) { + return { + ...price, + price: subscription.price!, + }; + } + return price; + }); + + return { + ...plan, + prices: updatedPrices, + }; + }); + }, [plans, subscription]); + + const [isSubscribing, setIsSubscribing] = useState({ + team: false, + business: false, + }); + + const subscribeToPlan = async (plan: Plan) => { + let name = plan?.name?.toLowerCase() || ""; + setIsSubscribing({ + team: name === PlanTier.TEAM, + business: name === PlanTier.BUSINESS, + }); + if (subscription?.active == true && !isTrial) { + await changeSubscription(plan, isAWS); + } else { + await subscribe(plan, isAWS); + } + setIsSubscribing({ + team: false, + business: false, + }); + }; + + return ( + + +
    + + } + /> + } + active + /> + + +
    +

    Plans & Billing

    +
    + + {isTrialAvailable && ( +
    + {currentPlan ? ( + + ) : ( + + )} +
    + )} + +
    + {currentPlan ? ( + + ) : ( + + )} +
    +
    + +
    +
    +
    +

    + {subscription?.active + ? "Update your NetBird Plan" + : "Upgrade your NetBird Plan"} +

    + +
    + + + Increase your user and peer limit by upgrading your plan. + + + With our flexible pricing, you are only billed for active users and + active peers. + + + Find out which{" "} + + Pricing Plan + + + suits you the best by visiting our website. + + +
    + {!plans && ( + <> + + + + )} + {teamAndBusinessPlans?.map((plan) => ( + + ))} +
    +
    +
    +
    + ); +}; diff --git a/src/modules/billing/Slider.tsx b/src/modules/billing/Slider.tsx new file mode 100644 index 0000000..47cc357 --- /dev/null +++ b/src/modules/billing/Slider.tsx @@ -0,0 +1,27 @@ +"use client"; + +import * as SliderPrimitive from "@radix-ui/react-slider"; +import { cn } from "@utils/helpers"; +import React from "react"; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/src/modules/billing/locked-feature/LockedFeatureBadge.tsx b/src/modules/billing/locked-feature/LockedFeatureBadge.tsx new file mode 100644 index 0000000..d00b870 --- /dev/null +++ b/src/modules/billing/locked-feature/LockedFeatureBadge.tsx @@ -0,0 +1,84 @@ +import { IconHelpCircle } from "@tabler/icons-react"; +import { cn } from "@utils/helpers"; +import { isNetBirdCloud } from "@utils/netbird"; +import { LockIcon } from "lucide-react"; +import * as React from "react"; +import { + PlanFeatureAvailability, + useIsFeatureLocked, +} from "@/cloud/cloud-hooks/useIsFeatureLocked"; +import { PLAN_TEXT } from "@/modules/billing/locked-feature/LockedFeatureContent"; +import { LockedFeatureInfoCardProps } from "@/modules/billing/locked-feature/LockedFeatureInfoCard"; +import { LockedFeatureTooltip } from "@/modules/billing/locked-feature/LockedFeatureTooltip"; + +type Props = { + position?: "absolute" | "relative"; + side?: "top" | "bottom" | "left" | "right"; + disabled?: boolean; + center?: boolean; +} & LockedFeatureInfoCardProps; + +export const LockedFeatureBadge = ({ + className = "", + children, + position = "absolute", + feature, + featureText, + side = "top", + disabled = false, + center = false, +}: Props) => { + const isLocked = useIsFeatureLocked(feature); + if (disabled) return <>{children}; + if (!isLocked) return <>{children}; + const plan = PlanFeatureAvailability[feature]; + + const preventKeyboardEvents = (e: React.KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + return ( +
    +
    + +
    + + {isNetBirdCloud() + ? plan == "team" + ? PLAN_TEXT.TEAM + : PLAN_TEXT.BUSINESS + : PLAN_TEXT.ENTERPRISE} + +
    +
    +
    +
    + {children} +
    +
    + ); +}; diff --git a/src/modules/billing/locked-feature/LockedFeatureContent.tsx b/src/modules/billing/locked-feature/LockedFeatureContent.tsx new file mode 100644 index 0000000..9e11b03 --- /dev/null +++ b/src/modules/billing/locked-feature/LockedFeatureContent.tsx @@ -0,0 +1,168 @@ +import Button from "@components/Button"; +import { cn } from "@utils/helpers"; +import { isNetBirdCloud } from "@utils/netbird"; +import { LockIcon, MailIcon } from "lucide-react"; +import * as React from "react"; +import { PlanFeatureAvailability } from "@/cloud/cloud-hooks/useIsFeatureLocked"; +import { useTrial } from "@/cloud/cloud-hooks/useTrial"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { PlanTier } from "@/interfaces/Subscription"; +import { LockedFeatureInfoCardProps } from "@/modules/billing/locked-feature/LockedFeatureInfoCard"; +import { TrialOrUpgradeButton } from "@/modules/billing/trial/TrialOrUpgradeButton"; + +export enum PLAN_TEXT { + TEAM = "Available on Team", + BUSINESS = "Available on Business", + ENTERPRISE = "Available with an Enterprise license", +} + +export const LockedFeatureContent = ({ + feature, + isTooltip = false, + featureText = "This", + isCard = false, + offerTrial = true, +}: LockedFeatureInfoCardProps) => { + const { isMSPInTenantContext, isAccountWithMSPParent } = useMSP(); + const { isOwnerOrAdmin } = useLoggedInUser(); + const plan = PlanFeatureAvailability[feature]; + + return ( + <> +
    +
    + + { isNetBirdCloud()? (plan == "team" ? PLAN_TEXT.TEAM : PLAN_TEXT.BUSINESS) : PLAN_TEXT.ENTERPRISE } +
    +
    + + {isCard &&
    } + +
    +
    + {(isOwnerOrAdmin || !isNetBirdCloud()) && ( +
    + } + side={side} + align={"center"} + > + {children} + + ); +}; diff --git a/src/modules/billing/trial/TrialGradientCard.tsx b/src/modules/billing/trial/TrialGradientCard.tsx new file mode 100644 index 0000000..47a990b --- /dev/null +++ b/src/modules/billing/trial/TrialGradientCard.tsx @@ -0,0 +1,57 @@ +import { IconInfoCircle } from "@tabler/icons-react"; +import { cn } from "@utils/helpers"; +import { Sparkles } from "lucide-react"; +import * as React from "react"; +import { useBilling } from "@/contexts/BillingProvider"; +import { PlanTier } from "@/interfaces/Subscription"; +import { TrialOrUpgradeButton } from "@/modules/billing/trial/TrialOrUpgradeButton"; + +export const TrialGradientCard = () => { + const { currentPlan, canUpgrade, isTrialAvailable } = useBilling(); + let planName = currentPlan?.name || "Free"; + if (!isTrialAvailable) return null; + + return ( +
    +
    +
    +
    +
    + + {`Try all of NetBird's features for free`} +
    +
    + {`Activate your 14-day trial to access NetBird's full set of features & integrations. After the trial, you will return to your ${planName} plan unless you choose to upgrade.`} +
    +
    + +
    +
    + {!canUpgrade && ( +
    + + Your plan was recently updated. Please wait for 48 hours from the last + update to change your plan again. +
    + )} +
    + ); +}; diff --git a/src/modules/billing/trial/TrialNavigationInfoCard.tsx b/src/modules/billing/trial/TrialNavigationInfoCard.tsx new file mode 100644 index 0000000..13fccae --- /dev/null +++ b/src/modules/billing/trial/TrialNavigationInfoCard.tsx @@ -0,0 +1,47 @@ +import Button from "@components/Button"; +import { cn } from "@utils/helpers"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { useTrial } from "@/cloud/cloud-hooks/useTrial"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { PlanIcon } from "@/modules/billing/PlanIcon"; + +export const TrialNavigationInfoCard = () => { + const router = useRouter(); + const { isOwnerOrAdmin } = useLoggedInUser(); + const { trialDaysRemaining } = useTrial(); + + return ( +
    +
    + +
    +
    Free Trial
    +
    + Trial ends in {trialDaysRemaining} days +
    +
    +
    + + {isOwnerOrAdmin && ( +
    + +
    + )} +
    + ); +}; diff --git a/src/modules/billing/trial/TrialOrUpgradeButton.tsx b/src/modules/billing/trial/TrialOrUpgradeButton.tsx new file mode 100644 index 0000000..848edce --- /dev/null +++ b/src/modules/billing/trial/TrialOrUpgradeButton.tsx @@ -0,0 +1,168 @@ +import Button from "@components/Button"; +import FullTooltip from "@components/FullTooltip"; +import { IconHelpCircle } from "@tabler/icons-react"; +import { cn } from "@utils/helpers"; +import { isNetBirdCloud } from "@utils/netbird"; +import { ExternalLinkIcon, Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { useState } from "react"; +import { PlanFeatures } from "@/cloud/cloud-hooks/useIsFeatureLocked"; +import { useTrial } from "@/cloud/cloud-hooks/useTrial"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { PlanTier } from "@/interfaces/Subscription"; + +type Props = { + plan?: PlanTier; + variant?: "primary" | "white"; + feature?: keyof typeof PlanFeatures; + isCard?: boolean; + isTooltip?: boolean; + offerTrial?: boolean; + hidden?: boolean; +}; + +export const TrialOrUpgradeButton = ({ + plan, + variant = "primary", + feature, + isCard = false, + isTooltip = false, + offerTrial = true, + hidden = false, +}: Props) => { + const { isTrialAvailable, startTrial, plans, canUpgrade } = useTrial(); + const [isLoading, setIsLoading] = useState(false); + const { isOwnerOrAdmin } = useLoggedInUser(); + const router = useRouter(); + if (hidden) return; + + const activateTrial = async () => { + if (!isOwnerOrAdmin) return; + if (!canUpgrade) return; + if (!plans) return; + const foundPlan = plans.find((p) => p.name.toLowerCase() == plan); + if (!foundPlan) return; + setIsLoading(true); + startTrial(foundPlan, feature).finally(() => setIsLoading(false)); + }; + + const RegularUserContent = () => { + return ( +
    +
    + {isTrialAvailable && offerTrial + ? "Only the owner or an administrator can start a free trial." + : "Only the owner or an administrator can upgrade the plan."} +
    +
    + ); + }; + + const PlansAndBillingButton = () => { + return ( +
    + +
    + ); + }; + + const TrialButton = () => { + return ( +
    + + Your plan was recently updated. Please wait for 48 hours from the + last update to change your plan again. +
    + } + > + + +
    + No credit card required +
    +
    + ); + }; + + if (!isNetBirdCloud()) return ; + if (!isOwnerOrAdmin) return ; + if (!isTrialAvailable) return ; + if (!offerTrial) return ; + if (!canUpgrade && isTooltip) return ; + return ; +}; + +export const SelfHostedUpgradeButton = ({ + variant = "primary", +}: { + variant?: "primary" | "white"; +}) => { + return ( + + ); +}; diff --git a/src/modules/billing/trial/TrialSuccessModal.tsx b/src/modules/billing/trial/TrialSuccessModal.tsx new file mode 100644 index 0000000..1f771e4 --- /dev/null +++ b/src/modules/billing/trial/TrialSuccessModal.tsx @@ -0,0 +1,61 @@ +import Button from "@components/Button"; +import { Modal, ModalContent } from "@components/modal/Modal"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import { Check, CircleCheckBig } from "lucide-react"; +import * as React from "react"; + +type Props = { + open: boolean; + setOpen: React.Dispatch>; +}; + +export const TrialSuccessModal = ({ open, setOpen }: Props) => { + return ( + + + +
    + +
    + Your 14-Day Trial has started! +
    +
    + {`Welcome aboard! You have now access to NetBird's full set of features & integrations `} + + for the next two weeks + + . +
    +
    +
    {`What's next?`}
    +
    + Explore the dashboard, our integrations and all the features. Some + of the key features you can try: +
    +
      +
    • + + Configure IdP sync for user & group provisioning +
    • +
    • + + Set up your first device posture checks +
    • +
    • + + Enable device approvals for added control +
    • +
    + +
    +
    +
    +
    + ); +}; diff --git a/src/modules/common-table-rows/ActiveInactiveRow.tsx b/src/modules/common-table-rows/ActiveInactiveRow.tsx index 8939078..e256b0d 100644 --- a/src/modules/common-table-rows/ActiveInactiveRow.tsx +++ b/src/modules/common-table-rows/ActiveInactiveRow.tsx @@ -11,7 +11,7 @@ type Props = { text?: string | React.ReactNode; className?: string; additionalInfo?: React.ReactNode; - dataCy?: string; + "data-testid"?: string; }; export default function ActiveInactiveRow({ @@ -22,7 +22,7 @@ export default function ActiveInactiveRow({ inactiveDot = "gray", className, additionalInfo, - dataCy, + "data-testid": dataTestId, }: Readonly) { return (
    {leftSection}
    diff --git a/src/modules/common-table-rows/GroupsRow.tsx b/src/modules/common-table-rows/GroupsRow.tsx index f4325c8..fe128c9 100644 --- a/src/modules/common-table-rows/GroupsRow.tsx +++ b/src/modules/common-table-rows/GroupsRow.tsx @@ -171,7 +171,7 @@ export function EditGroupsModal({ -
    diff --git a/src/modules/dns/nameservers/NameserverModal.tsx b/src/modules/dns/nameservers/NameserverModal.tsx index 5fa42d6..22e09b3 100644 --- a/src/modules/dns/nameservers/NameserverModal.tsx +++ b/src/modules/dns/nameservers/NameserverModal.tsx @@ -264,7 +264,7 @@ export function NameserverModalContent({ setTab(v)} value={tab}> - + {t("tabNameserver")} - + {t("tabDomains")} - + setNameservers({ type: "ADD" })} + data-testid="add-nameserver-row" > {t("createNameserver")} @@ -342,12 +351,14 @@ export function NameserverModalContent({ onChange={setGroups} values={groups} disabled={!canAction} + data-testid="nameserver-groups-selector" />
    @@ -400,6 +411,7 @@ export function NameserverModalContent({ size={"sm"} onClick={() => setDomains({ type: "ADD" })} disabled={!canAction} + data-testid="add-match-domain" > {t("addDomain")} @@ -414,6 +426,7 @@ export function NameserverModalContent({ @@ -439,6 +452,7 @@ export function NameserverModalContent({ value={name} onChange={(e) => setName(e.target.value)} disabled={!canAction} + data-testid="nameserver-name-input" />
    @@ -452,6 +466,7 @@ export function NameserverModalContent({ rows={3} disabled={!canAction} onChange={(e) => setDescription(e.target.value)} + data-testid="nameserver-description-input" />
    @@ -494,6 +509,7 @@ export function NameserverModalContent({ variant={"primary"} onClick={() => setTab("domains")} disabled={!canContinueToDomains} + data-testid="nameserver-continue" > {tCommon("next")} @@ -504,6 +520,7 @@ export function NameserverModalContent({ variant={"primary"} onClick={() => setTab("general")} disabled={!canContinueToGeneral} + data-testid="nameserver-continue" > {tCommon("next")} @@ -522,6 +539,7 @@ export function NameserverModalContent({ variant={"primary"} disabled={!canSubmit || !canAction} onClick={submit} + data-testid="submit-nameserver" > {t("createNameserver")} @@ -538,6 +556,7 @@ export function NameserverModalContent({ variant={"primary"} disabled={!canSubmit || !canAction} onClick={submit} + data-testid="submit-nameserver" > {t("saveChanges")} @@ -605,6 +624,7 @@ function NameserverInput({ error={cidrError} onChange={handleIPChange} disabled={disabled} + data-testid="nameserver-ip-input" /> @@ -616,12 +636,14 @@ function NameserverInput({ type={"number"} onChange={handlePortChange} disabled={disabled} + data-testid="nameserver-port-input" /> diff --git a/src/modules/dns/nameservers/NameserverTemplateModal.tsx b/src/modules/dns/nameservers/NameserverTemplateModal.tsx index 73541bb..2e7fede 100644 --- a/src/modules/dns/nameservers/NameserverTemplateModal.tsx +++ b/src/modules/dns/nameservers/NameserverTemplateModal.tsx @@ -75,6 +75,7 @@ export function NameserverTemplateModalContent({ "A free, global DNS resolution service by Google that implements a number of security, performance, and compliance improvements." } href={"https://developers.google.com/speed/public-dns"} + data-testid="nameserver-preset-google" /> onePresetSelection(NameserverPresets.Cloudflare)} @@ -84,6 +85,7 @@ export function NameserverTemplateModalContent({ "Enterprise-grade DNS service that offers the fastest response time, unparalleled redundancy, and advanced security with built-in DDoS mitigation and DNSSEC." } href={"https://www.cloudflare.com/learning/dns/what-is-1.1.1.1/"} + data-testid="nameserver-preset-cloudflare" /> onePresetSelection(NameserverPresets.Quad9)} @@ -93,6 +95,7 @@ export function NameserverTemplateModalContent({ "The Quad9 DNS service is operated by the Swiss-based Quad9 Foundation, whose mission is to provide a safer and more robust Internet for everyone." } href={"https://quad9.net/"} + data-testid="nameserver-preset-quad9" /> onePresetSelection(NameserverPresets.Default)} @@ -101,6 +104,7 @@ export function NameserverTemplateModalContent({ description={ "Use custom nameservers to resolve domains in your network. You can either use a public DNS or your own nameservers." } + data-testid="nameserver-preset-custom" /> @@ -116,6 +120,7 @@ function NameserverTemplate({ onClick, href, hrefTitle, + "data-testid": dataTestId, }: Readonly<{ src?: StaticImageData; icon?: React.ReactNode; @@ -124,6 +129,7 @@ function NameserverTemplate({ onClick?: () => void; href?: string; hrefTitle?: string; + "data-testid"?: string; }>) { return ( @@ -107,6 +108,7 @@ export default function NameserverActionCell({ ns }: Readonly) { handleToggle(); }} disabled={!canUpdate} + data-testid="nameserver-active-toggle" >
    @@ -118,6 +120,7 @@ export default function NameserverActionCell({ ns }: Readonly) { onClick={openConfirm} disabled={!canDelete} variant={"danger"} + data-testid="delete-nameserver" >
    diff --git a/src/modules/dns/nameservers/table/NameserverGroupTable.tsx b/src/modules/dns/nameservers/table/NameserverGroupTable.tsx index e74dbf1..b4eaed4 100644 --- a/src/modules/dns/nameservers/table/NameserverGroupTable.tsx +++ b/src/modules/dns/nameservers/table/NameserverGroupTable.tsx @@ -290,6 +290,7 @@ export default function NameserverGroupTable({ variant={"primary"} className={""} disabled={!permission.nameservers.create} + data-testid="open-add-nameserver" > Add Nameserver @@ -323,6 +324,7 @@ export default function NameserverGroupTable({ variant={"primary"} className={"ml-auto"} disabled={!permission.nameservers.create} + data-testid="open-add-nameserver" > Add Nameserver diff --git a/src/modules/dns/nameservers/table/NameserverNameCell.tsx b/src/modules/dns/nameservers/table/NameserverNameCell.tsx index 4e42065..235922c 100644 --- a/src/modules/dns/nameservers/table/NameserverNameCell.tsx +++ b/src/modules/dns/nameservers/table/NameserverNameCell.tsx @@ -13,6 +13,7 @@ export default function NameserverNameCell({ ns }: Props) { className={ "flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer" } + data-testid="nameserver-name-cell" > @@ -228,6 +228,7 @@ export function DNSRecordModalContent({ className={"rounded-r-none"} maxWidthClass={"w-full"} onChange={(e) => setDomain(e.target.value)} + data-testid="dns-record-hostname-input" />
    setRecordValue(e.target.value)} + data-testid="dns-record-content-input" />
    )} @@ -268,6 +270,7 @@ export function DNSRecordModalContent({ value={recordValue} maxWidthClass={"w-full"} onChange={(e) => setRecordValue(e.target.value)} + data-testid="dns-record-content-input" />
    )} @@ -284,6 +287,7 @@ export function DNSRecordModalContent({ value={recordValue} maxWidthClass={"w-full"} onChange={(e) => setRecordValue(e.target.value)} + data-testid="dns-record-content-input" />
    )} @@ -294,7 +298,7 @@ export function DNSRecordModalContent({