Merge NetBird cloud edition into the dashboard (#674)
Brings the unified dashboard into the open-source repo. Premium features ship in the open code, gated at runtime via NETBIRD_CLOUD and NETBIRD_LICENSED, with upgrade prompts for unlicensed self-hosted deployments. Adds the cloud-only feature areas (billing, integrations, MSP, traffic events, notifications) and the Playwright e2e suite.
7
.github/pull_request_template.md
vendored
@@ -10,3 +10,10 @@ Select exactly one:
|
|||||||
Paste the PR link from https://github.com/netbirdio/docs here:
|
Paste the PR link from https://github.com/netbirdio/docs here:
|
||||||
|
|
||||||
https://github.com/netbirdio/docs/pull/__
|
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
|
||||||
|
|||||||
55
.github/workflows/build_and_push.yml
vendored
@@ -7,17 +7,24 @@ on:
|
|||||||
- "**"
|
- "**"
|
||||||
pull_request:
|
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:
|
env:
|
||||||
IMAGE_NAME: netbirdio/dashboard
|
DOCKERHUB_IMAGE: netbirdio/dashboard
|
||||||
|
GHCR_IMAGE: ghcr.io/netbirdio/dashboard-cloud
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_n_push:
|
build_n_push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: setup-node
|
- name: setup-node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -69,25 +76,43 @@ jobs:
|
|||||||
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
|
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v4
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.IMAGE_NAME }}
|
images: |
|
||||||
-
|
${{ env.DOCKERHUB_IMAGE }}
|
||||||
name: Login to DockerHub
|
${{ env.GHCR_IMAGE }}
|
||||||
uses: docker/login-action@v2
|
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:
|
with:
|
||||||
username: ${{ secrets.NB_DOCKER_USER }}
|
username: ${{ secrets.NB_DOCKER_USER }}
|
||||||
password: ${{ secrets.NB_DOCKER_TOKEN }}
|
password: ${{ secrets.NB_DOCKER_TOKEN }}
|
||||||
-
|
|
||||||
name: Docker build and push
|
- name: Log in to the GitHub Container registry
|
||||||
uses: docker/build-push-action@v3
|
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:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/Dockerfile
|
file: docker/Dockerfile
|
||||||
@@ -95,3 +120,7 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64,linux/arm
|
platforms: linux/amd64,linux/arm64,linux/arm
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
162
.github/workflows/e2e-test.yml
vendored
Normal file
@@ -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: <tag>` or `reverse-proxy-tag: <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
|
||||||
9
.gitignore
vendored
@@ -36,9 +36,14 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# config
|
# config
|
||||||
.local-config.json
|
.local*config*.json
|
||||||
.test-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/.local-config.zitadel.json
|
||||||
.configs/.staging-config.json
|
.configs/.staging-config.json
|
||||||
.configs/.temp-config.json
|
.configs/.temp-config.json
|
||||||
|
|||||||
2
AUTHORS
@@ -1,3 +1,3 @@
|
|||||||
Mikhail Bragin (https://github.com/braginini)
|
Mikhail Bragin (https://github.com/braginini)
|
||||||
Maycon Santos (https://github.com/mlsmaycon)
|
Maycon Santos (https://github.com/mlsmaycon)
|
||||||
Wiretrustee UG (haftungsbeschränkt)
|
NetBird GmbH
|
||||||
10
config.json
@@ -14,5 +14,13 @@
|
|||||||
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
|
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
|
||||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/// <reference types="cypress" />
|
|
||||||
// ***********************************************
|
|
||||||
// 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<void>
|
|
||||||
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
|
||||||
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
|
||||||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
@@ -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')
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es5",
|
|
||||||
"lib": ["es5", "dom"],
|
|
||||||
"baseUrl": "http://localhost:3000",
|
|
||||||
"types": ["cypress", "node"],
|
|
||||||
},
|
|
||||||
"include": ["**/*.ts"]
|
|
||||||
}
|
|
||||||
@@ -14,14 +14,24 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri.html $uri/ =404;
|
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;
|
expires off;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 404 /404.html;
|
error_page 404 /404.html;
|
||||||
location = /404.html {
|
location = /404.html {
|
||||||
internal;
|
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;
|
expires off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
|
||||||
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
|
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
|
||||||
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
|
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_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}"
|
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
|
# 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"
|
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||||
|
|||||||
233
e2e/CLAUDE.md
Normal file
@@ -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/<file>` — 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.
|
||||||
12
e2e/environment/.gitignore
vendored
Normal file
@@ -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
|
||||||
25
e2e/environment/clean-test-env.sh
Normal file
@@ -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
|
||||||
927
e2e/environment/create-test-env.sh
Normal file
@@ -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 <<PROXYEOF
|
||||||
|
NB_PROXY_TOKEN=$NB_PROXY_TOKEN
|
||||||
|
NB_PROXY_ALLOW_INSECURE=true
|
||||||
|
NB_PROXY_LOG_LEVEL=trace
|
||||||
|
PROXYEOF
|
||||||
|
|
||||||
|
# Secondary proxy (custom ports disabled)
|
||||||
|
NB_PROXY_TOKEN_NO_PORTS=$(create_proxy_token "test-proxy-no-ports")
|
||||||
|
cat > proxy-no-ports.env <<PROXYEOF
|
||||||
|
NB_PROXY_TOKEN=$NB_PROXY_TOKEN_NO_PORTS
|
||||||
|
NB_PROXY_ALLOW_INSECURE=true
|
||||||
|
PROXYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
init_zitadel() {
|
||||||
|
echo -e "\nInitializing Zitadel with NetBird's applications\n"
|
||||||
|
INSTANCE_URL="$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT"
|
||||||
|
|
||||||
|
TOKEN_PATH=./machinekey/zitadel-admin-sa.token
|
||||||
|
|
||||||
|
echo -n "Waiting for Zitadel's PAT to be created "
|
||||||
|
wait_pat "$TOKEN_PATH"
|
||||||
|
echo "Reading Zitadel PAT"
|
||||||
|
PAT=$(cat $TOKEN_PATH)
|
||||||
|
if [ "$PAT" = "null" ]; then
|
||||||
|
echo "Failed requesting getting Zitadel PAT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n "Waiting for Zitadel to become ready "
|
||||||
|
wait_api "$INSTANCE_URL" "$PAT"
|
||||||
|
|
||||||
|
# create the zitadel project
|
||||||
|
echo "Creating new zitadel project"
|
||||||
|
PROJECT_ID=$(create_new_project "$INSTANCE_URL" "$PAT")
|
||||||
|
|
||||||
|
ZITADEL_DEV_MODE=false
|
||||||
|
BASE_REDIRECT_URL=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN
|
||||||
|
if [[ $NETBIRD_HTTP_PROTOCOL == "http" ]]; then
|
||||||
|
ZITADEL_DEV_MODE=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# create zitadel spa applications
|
||||||
|
echo "Creating new Zitadel SPA Dashboard application"
|
||||||
|
DASHBOARD_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Dashboard" "http://localhost:1337/nb-auth" "http://localhost:1337/nb-silent-auth" "http://localhost:1337/" "true")
|
||||||
|
|
||||||
|
echo "Creating new Zitadel SPA Cli application"
|
||||||
|
CLI_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Cli" "http://localhost:53000/" "http://localhost:54000/" "http://localhost:53000/" "true")
|
||||||
|
|
||||||
|
MACHINE_USER_ID=$(create_service_user "$INSTANCE_URL" "$PAT")
|
||||||
|
|
||||||
|
SERVICE_USER_CLIENT_ID="null"
|
||||||
|
SERVICE_USER_CLIENT_SECRET="null"
|
||||||
|
|
||||||
|
create_service_user_secret "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID"
|
||||||
|
|
||||||
|
DATE=$(add_organization_user_manager "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID")
|
||||||
|
|
||||||
|
ZITADEL_ADMIN_USERNAME="owner@localhost.test"
|
||||||
|
ZITADEL_ADMIN_PASSWORD="testMe123@"
|
||||||
|
|
||||||
|
HUMAN_USER_ID=$(create_admin_user "$INSTANCE_URL" "$PAT" "$ZITADEL_ADMIN_USERNAME" "$ZITADEL_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
DATE="null"
|
||||||
|
|
||||||
|
DATE=$(add_instance_admin "$INSTANCE_URL" "$PAT" "$HUMAN_USER_ID")
|
||||||
|
|
||||||
|
# Create a second user for role-based testing (e.g., Billing Admin)
|
||||||
|
ZITADEL_SECOND_USERNAME="user@localhost.test"
|
||||||
|
ZITADEL_SECOND_PASSWORD="testMe123@"
|
||||||
|
|
||||||
|
SECOND_USER_ID=$(create_admin_user "$INSTANCE_URL" "$PAT" "$ZITADEL_SECOND_USERNAME" "$ZITADEL_SECOND_PASSWORD" "Zitadel" "User")
|
||||||
|
DATE=$(add_instance_admin "$INSTANCE_URL" "$PAT" "$SECOND_USER_ID")
|
||||||
|
|
||||||
|
DATE="null"
|
||||||
|
DATE=$(delete_auto_service_user "$INSTANCE_URL" "$PAT")
|
||||||
|
if [ "$DATE" = "null" ]; then
|
||||||
|
echo "Failed deleting auto service user"
|
||||||
|
echo "Please remove it manually"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export NETBIRD_AUTH_CLIENT_ID=$DASHBOARD_APPLICATION_CLIENT_ID
|
||||||
|
export NETBIRD_AUTH_CLIENT_ID_CLI=$CLI_APPLICATION_CLIENT_ID
|
||||||
|
export NETBIRD_IDP_MGMT_CLIENT_ID=$SERVICE_USER_CLIENT_ID
|
||||||
|
export NETBIRD_IDP_MGMT_CLIENT_SECRET=$SERVICE_USER_CLIENT_SECRET
|
||||||
|
export ZITADEL_ADMIN_USERNAME
|
||||||
|
export ZITADEL_ADMIN_PASSWORD
|
||||||
|
}
|
||||||
|
|
||||||
|
check_nb_domain() {
|
||||||
|
DOMAIN=$1
|
||||||
|
if [ "$DOMAIN-x" == "-x" ]; then
|
||||||
|
echo "The NETBIRD_DOMAIN variable cannot be empty." > /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 <<EOF
|
||||||
|
{
|
||||||
|
debug
|
||||||
|
servers :80,:443 {
|
||||||
|
protocols h1 h2c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:80${CADDY_SECURE_DOMAIN} {
|
||||||
|
# Signal
|
||||||
|
reverse_proxy /signalexchange.SignalExchange/* h2c://signal:10000
|
||||||
|
# Management
|
||||||
|
reverse_proxy /api/* management:80
|
||||||
|
reverse_proxy /management.ManagementService/* h2c://management:80
|
||||||
|
# Zitadel
|
||||||
|
reverse_proxy /zitadel.admin.v1.AdminService/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /admin/v1/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /zitadel.auth.v1.AuthService/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /auth/v1/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /zitadel.management.v1.ManagementService/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /management/v1/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /zitadel.system.v1.SystemService/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /system/v1/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /assets/v1/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /ui/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /oidc/v1/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /saml/v2/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /oauth/v2/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /.well-known/openid-configuration h2c://zitadel:8080
|
||||||
|
reverse_proxy /openapi/* h2c://zitadel:8080
|
||||||
|
reverse_proxy /debug/* h2c://zitadel:8080
|
||||||
|
# Dashboard
|
||||||
|
reverse_proxy /* dashboard:80
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTurnServerConf() {
|
||||||
|
cat <<EOF
|
||||||
|
listening-port=3478
|
||||||
|
tls-listening-port=5349
|
||||||
|
min-port=$TURN_MIN_PORT
|
||||||
|
max-port=$TURN_MAX_PORT
|
||||||
|
fingerprint
|
||||||
|
lt-cred-mech
|
||||||
|
user=$TURN_USER:$TURN_PASSWORD
|
||||||
|
realm=netbird.io
|
||||||
|
cert=/etc/coturn/certs/cert.pem
|
||||||
|
pkey=/etc/coturn/private/privkey.pem
|
||||||
|
log-file=stdout
|
||||||
|
no-software-attribute
|
||||||
|
pidfile="/var/tmp/turnserver.pid"
|
||||||
|
no-cli
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
renderManagementJson() {
|
||||||
|
cat <<EOF
|
||||||
|
{
|
||||||
|
"StoreConfig": {
|
||||||
|
"Engine": "postgres"
|
||||||
|
},
|
||||||
|
"Stuns": [
|
||||||
|
{
|
||||||
|
"Proto": "udp",
|
||||||
|
"URI": "stun:$NETBIRD_DOMAIN:3478"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TURNConfig": {
|
||||||
|
"Turns": [
|
||||||
|
{
|
||||||
|
"Proto": "udp",
|
||||||
|
"URI": "turn:$NETBIRD_DOMAIN:3478",
|
||||||
|
"Username": "$TURN_USER",
|
||||||
|
"Password": "$TURN_PASSWORD"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TimeBasedCredentials": false
|
||||||
|
},
|
||||||
|
"Signal": {
|
||||||
|
"Proto": "$NETBIRD_HTTP_PROTOCOL",
|
||||||
|
"URI": "$NETBIRD_DOMAIN:$NETBIRD_PORT"
|
||||||
|
},
|
||||||
|
"HttpConfig": {
|
||||||
|
"AuthIssuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||||
|
"AuthAudience": "$NETBIRD_AUTH_CLIENT_ID",
|
||||||
|
"OIDCConfigEndpoint":"$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/.well-known/openid-configuration"
|
||||||
|
},
|
||||||
|
"IdpManagerConfig": {
|
||||||
|
"ManagerType": "zitadel",
|
||||||
|
"ClientConfig": {
|
||||||
|
"Issuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||||
|
"TokenEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/oauth/v2/token",
|
||||||
|
"ClientID": "$NETBIRD_IDP_MGMT_CLIENT_ID",
|
||||||
|
"ClientSecret": "$NETBIRD_IDP_MGMT_CLIENT_SECRET",
|
||||||
|
"GrantType": "client_credentials"
|
||||||
|
},
|
||||||
|
"ExtraConfig": {
|
||||||
|
"ManagementEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/management/v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PKCEAuthorizationFlow": {
|
||||||
|
"ProviderConfig": {
|
||||||
|
"Audience": "$NETBIRD_AUTH_CLIENT_ID_CLI",
|
||||||
|
"ClientID": "$NETBIRD_AUTH_CLIENT_ID_CLI",
|
||||||
|
"Scope": "openid profile email offline_access",
|
||||||
|
"RedirectURLs": ["http://localhost:53000/","http://localhost:54000/"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDashboardEnv() {
|
||||||
|
cat <<EOF
|
||||||
|
{
|
||||||
|
"auth0Auth": "false",
|
||||||
|
"authAuthority": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||||
|
"authClientId": "$NETBIRD_AUTH_CLIENT_ID",
|
||||||
|
"authScopesSupported": "openid profile email offline_access",
|
||||||
|
"authAudience": "$NETBIRD_AUTH_CLIENT_ID",
|
||||||
|
"apiOrigin": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||||
|
"grpcApiOrigin": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||||
|
"redirectURI": "/nb-auth",
|
||||||
|
"silentRedirectURI": "/nb-silent-auth"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
renderZitadelEnv() {
|
||||||
|
cat <<EOF
|
||||||
|
ZITADEL_LOG_LEVEL=debug
|
||||||
|
ZITADEL_MASTERKEY=$ZITADEL_MASTERKEY
|
||||||
|
ZITADEL_DATABASE_COCKROACH_HOST=crdb
|
||||||
|
ZITADEL_DATABASE_COCKROACH_USER_USERNAME=zitadel_user
|
||||||
|
ZITADEL_DATABASE_COCKROACH_USER_SSL_MODE=verify-full
|
||||||
|
ZITADEL_DATABASE_COCKROACH_USER_SSL_ROOTCERT="/crdb-certs/ca.crt"
|
||||||
|
ZITADEL_DATABASE_COCKROACH_USER_SSL_CERT="/crdb-certs/client.zitadel_user.crt"
|
||||||
|
ZITADEL_DATABASE_COCKROACH_USER_SSL_KEY="/crdb-certs/client.zitadel_user.key"
|
||||||
|
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_MODE=verify-full
|
||||||
|
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_ROOTCERT="/crdb-certs/ca.crt"
|
||||||
|
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_CERT="/crdb-certs/client.root.crt"
|
||||||
|
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_KEY="/crdb-certs/client.root.key"
|
||||||
|
ZITADEL_EXTERNALSECURE=$ZITADEL_EXTERNALSECURE
|
||||||
|
ZITADEL_TLS_ENABLED="false"
|
||||||
|
ZITADEL_EXTERNALPORT=$NETBIRD_PORT
|
||||||
|
ZITADEL_EXTERNALDOMAIN=$NETBIRD_DOMAIN
|
||||||
|
ZITADEL_FIRSTINSTANCE_PATPATH=/machinekey/zitadel-admin-sa.token
|
||||||
|
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME=zitadel-admin-sa
|
||||||
|
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME=Admin
|
||||||
|
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_SCOPES=openid
|
||||||
|
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE=$ZIDATE_TOKEN_EXPIRATION_DATE
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDockerCompose() {
|
||||||
|
cat <<EOF
|
||||||
|
version: "3.4"
|
||||||
|
services:
|
||||||
|
# Caddy reverse proxy
|
||||||
|
caddy:
|
||||||
|
image: caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [ netbird ]
|
||||||
|
ports:
|
||||||
|
- '33443:443'
|
||||||
|
- '33080:80'
|
||||||
|
- '33880:8080'
|
||||||
|
volumes:
|
||||||
|
- netbird_caddy_data:/data
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
# Management
|
||||||
|
management:
|
||||||
|
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [netbird]
|
||||||
|
environment:
|
||||||
|
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=host=postgres user=netbird password=netbird dbname=netbird port=5432
|
||||||
|
- NB_TRAFFIC_EVENT_POSTGRES_DSN=host=postgres user=netbird password=netbird dbname=netbird port=5432
|
||||||
|
- NETBIRD_STORE_CONFIG_ENGINE=postgres
|
||||||
|
- NB_TRAFFIC_EVENT_STORE_ENGINE=postgres
|
||||||
|
- NB_LICENSE_KEY=${NB_LICENSE_KEY}
|
||||||
|
- NB_TRAFFIC_FLOW_ADDRESS=http://127.0.0.1:8084
|
||||||
|
- NETBIRD_DATADIR=/var/lib/netbird/
|
||||||
|
- NETBIRD_ENCRYPTION_KEY=saFhCwIBtO+4QfRqMA19kKYqNPSrtXq7+TVWfHax+3I=
|
||||||
|
- NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}
|
||||||
|
- NB_TRAFFIC_FLOW_INTERVAL=20s
|
||||||
|
- NB_SINGLE_INSTANCE_MODE=true
|
||||||
|
volumes:
|
||||||
|
- netbird_management:/var/lib/netbird
|
||||||
|
- ./management.json:/etc/netbird/management.json
|
||||||
|
command: [
|
||||||
|
"--port", "80",
|
||||||
|
"--log-file", "console",
|
||||||
|
"--log-level", "trace",
|
||||||
|
"--disable-anonymous-metrics=false",
|
||||||
|
"--single-account-mode-domain=netbird.selfhosted",
|
||||||
|
"--dns-domain=netbird.selfhosted",
|
||||||
|
"--idp-sign-key-refresh-enabled",
|
||||||
|
]
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: 'service_healthy'
|
||||||
|
# PostgreSQL for management
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [netbird]
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=netbird
|
||||||
|
- POSTGRES_PASSWORD=netbird
|
||||||
|
- POSTGRES_DB=netbird
|
||||||
|
volumes:
|
||||||
|
- netbird_postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U netbird"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
# Zitadel - identity provider
|
||||||
|
zitadel:
|
||||||
|
restart: 'always'
|
||||||
|
networks: [netbird]
|
||||||
|
image: 'ghcr.io/zitadel/zitadel:v2.31.3'
|
||||||
|
command: 'start-from-init --masterkeyFromEnv --tlsMode $ZITADEL_TLS_MODE'
|
||||||
|
env_file:
|
||||||
|
- ./zitadel.env
|
||||||
|
depends_on:
|
||||||
|
crdb:
|
||||||
|
condition: 'service_healthy'
|
||||||
|
volumes:
|
||||||
|
- ./machinekey:/machinekey
|
||||||
|
- netbird_zitadel_certs:/crdb-certs:ro
|
||||||
|
# Reverse proxy (supports custom listen ports for UDP/TCP)
|
||||||
|
reverse-proxy:
|
||||||
|
image: ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [netbird]
|
||||||
|
env_file:
|
||||||
|
- ./proxy.env
|
||||||
|
volumes:
|
||||||
|
- ./proxy-certs:/certs:ro
|
||||||
|
command: [
|
||||||
|
"--mgmt", "http://management:80",
|
||||||
|
"--addr", "0.0.0.0:8443",
|
||||||
|
"--domain", "example.com",
|
||||||
|
"--cert-dir", "/certs",
|
||||||
|
"--debug-endpoint",
|
||||||
|
"--debug-endpoint-addr", "0.0.0.0:8444",
|
||||||
|
"--health-addr", "0.0.0.0:8080",
|
||||||
|
"--log-level", "debug",
|
||||||
|
]
|
||||||
|
depends_on:
|
||||||
|
- management
|
||||||
|
# Reverse proxy with custom ports disabled (auto-assigned listen ports only)
|
||||||
|
reverse-proxy-no-ports:
|
||||||
|
image: ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [netbird]
|
||||||
|
env_file:
|
||||||
|
- ./proxy-no-ports.env
|
||||||
|
volumes:
|
||||||
|
# Distinct cert dir so this proxy has a distinct identity from the
|
||||||
|
# primary proxy; a shared cert makes both register under the same
|
||||||
|
# proxy ID and management superseding kicks one off in a loop, which
|
||||||
|
# makes cluster registration (and the reverse-proxy suite) flaky.
|
||||||
|
- ./proxy-certs-no-ports:/certs:ro
|
||||||
|
command: [
|
||||||
|
"--mgmt", "http://management:80",
|
||||||
|
"--addr", "0.0.0.0:9443",
|
||||||
|
"--domain", "noports.example.com",
|
||||||
|
"--cert-dir", "/certs",
|
||||||
|
"--debug-endpoint",
|
||||||
|
"--debug-endpoint-addr", "0.0.0.0:9444",
|
||||||
|
"--health-addr", "0.0.0.0:9080",
|
||||||
|
"--log-level", "debug",
|
||||||
|
"--supports-custom-ports=false",
|
||||||
|
]
|
||||||
|
depends_on:
|
||||||
|
- management
|
||||||
|
# CockroachDB for zitadel
|
||||||
|
crdb:
|
||||||
|
restart: 'always'
|
||||||
|
networks: [netbird]
|
||||||
|
image: 'cockroachdb/cockroach:v22.2.2'
|
||||||
|
command: 'start-single-node --advertise-addr crdb'
|
||||||
|
volumes:
|
||||||
|
- netbird_crdb_data:/cockroach/cockroach-data
|
||||||
|
- netbird_crdb_certs:/cockroach/certs
|
||||||
|
- netbird_zitadel_certs:/zitadel-certs
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "curl", "-f", "http://localhost:8080/health?ready=1" ]
|
||||||
|
interval: '10s'
|
||||||
|
timeout: '30s'
|
||||||
|
retries: 5
|
||||||
|
start_period: '20s'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
netbird_management:
|
||||||
|
netbird_caddy_data:
|
||||||
|
netbird_crdb_data:
|
||||||
|
netbird_crdb_certs:
|
||||||
|
netbird_zitadel_certs:
|
||||||
|
netbird_postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
netbird:
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPlaywrightEnv() {
|
||||||
|
cat <<EOF
|
||||||
|
{
|
||||||
|
"ZITADEL_URL": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||||
|
"BASE_URL": "http://localhost:1337"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
initEnvironment
|
||||||
0
e2e/fixtures/auth/.gitkeep
Normal file
435
e2e/helpers/api.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
/**
|
||||||
|
* Direct API helpers for fast CRUD operations in tests.
|
||||||
|
*
|
||||||
|
* The app uses OIDC service-worker auth, so page.request doesn't carry
|
||||||
|
* the Bearer token. We extract it from the browser context and pass it
|
||||||
|
* explicitly via page.evaluate + fetch.
|
||||||
|
*/
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
peers_count: number;
|
||||||
|
resources_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture the auth token and API origin by intercepting a real network
|
||||||
|
* response from the management API. We listen for any /api/ response
|
||||||
|
* and extract the request's Authorization header (injected by the OIDC
|
||||||
|
* service worker at the network level).
|
||||||
|
*/
|
||||||
|
const apiContextCache = new WeakMap<Page, { token: string; origin: string }>();
|
||||||
|
|
||||||
|
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<T>(page: Page, path: string): Promise<T> {
|
||||||
|
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<void> {
|
||||||
|
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<Group[]> {
|
||||||
|
return apiGet<Group[]>(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<Network[]> {
|
||||||
|
return apiGet<Network[]>(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<Policy[]> {
|
||||||
|
return apiGet<Policy[]>(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<Route[]> {
|
||||||
|
return apiGet<Route[]>(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<SetupKey[]> {
|
||||||
|
return apiGet<SetupKey[]>(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<DnsZone[]> {
|
||||||
|
return apiGet<DnsZone[]>(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<NotificationChannel[]> {
|
||||||
|
return apiGet<NotificationChannel[]>(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<NameserverGroup[]> {
|
||||||
|
return apiGet<NameserverGroup[]>(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<ReverseProxyService[]> {
|
||||||
|
return apiGet<ReverseProxyService[]>(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<ReverseProxyCluster[]> {
|
||||||
|
return apiGet<ReverseProxyCluster[]>(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<void> {
|
||||||
|
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<User[]> {
|
||||||
|
return apiGet<User[]>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
e2e/helpers/auth.ts
Normal file
@@ -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<TestUser, { username: string; password: string }> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
49
e2e/helpers/fixtures.ts
Normal file
@@ -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";
|
||||||
8
e2e/helpers/navigation.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
232
e2e/helpers/reverse-proxy-l4.ts
Normal file
@@ -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<string> {
|
||||||
|
// 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<string> {
|
||||||
|
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 ".<domain>" 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();
|
||||||
|
}
|
||||||
103
e2e/helpers/utils.ts
Normal file
@@ -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<T>(
|
||||||
|
page: Page,
|
||||||
|
action: () => Promise<T>,
|
||||||
|
{
|
||||||
|
pattern = "/api/reverse-prox",
|
||||||
|
quietMs = 500,
|
||||||
|
timeoutMs = 15_000,
|
||||||
|
}: { pattern?: string; quietMs?: number; timeoutMs?: number } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
}
|
||||||
54
e2e/playwright.config.ts
Normal file
@@ -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"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
116
e2e/tests/access-control-groups.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
151
e2e/tests/access-control.spec.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
168
e2e/tests/dns-nameservers.spec.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
89
e2e/tests/dns-settings.spec.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
197
e2e/tests/dns-zones.spec.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
216
e2e/tests/edition-gating.spec.ts
Normal file
@@ -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<void> }> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
109
e2e/tests/login.spec.ts
Normal file
@@ -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<TestUser, { username: string; password: string }> = {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
176
e2e/tests/network-routes.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
164
e2e/tests/networks.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
167
e2e/tests/reverse-proxy-crowdsec.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
e2e/tests/reverse-proxy-custom-domains.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
275
e2e/tests/reverse-proxy-services-https.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
115
e2e/tests/reverse-proxy-services-tcp.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
117
e2e/tests/reverse-proxy-services-tls.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
119
e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
111
e2e/tests/reverse-proxy-services-udp.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
80
e2e/tests/settings-authentication.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
124
e2e/tests/settings-clients.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
24
e2e/tests/settings-groups.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
240
e2e/tests/settings-networks.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
75
e2e/tests/settings-notifications-email.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
55
e2e/tests/settings-notifications-slack.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
130
e2e/tests/settings-notifications-webhook.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
e2e/tests/settings-permissions.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
161
e2e/tests/setup-keys.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
115
e2e/tests/team-service-users.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
150
e2e/tests/team-users-approval-and-billing.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
e2e/tests/team-users.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
1052
package-lock.json
generated
20
package.json
@@ -11,9 +11,15 @@
|
|||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
"turbo": "next dev -p 3000 --turbo",
|
"turbo": "next dev -p 3000 --turbo",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"postbuild": "node postbuild.js",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"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": {
|
"dependencies": {
|
||||||
"@axa-fr/react-oidc": "^7.26.3",
|
"@axa-fr/react-oidc": "^7.26.3",
|
||||||
@@ -40,7 +46,7 @@
|
|||||||
"@tanstack/react-table": "^8.10.7",
|
"@tanstack/react-table": "^8.10.7",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/lodash": "^4.14.200",
|
"@types/lodash": "4.17.24",
|
||||||
"@types/node": "20.10.6",
|
"@types/node": "20.10.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -55,6 +61,7 @@
|
|||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
@@ -63,15 +70,17 @@
|
|||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||||
"framer-motion": "^12.29.2",
|
"framer-motion": "^12.29.2",
|
||||||
"ip-address": "^10.1.0",
|
"ip-address": "^10.2.0",
|
||||||
"ip-cidr": "^3.1.0",
|
"ip-cidr": "^3.1.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.7",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "4.18.1",
|
||||||
"lucide-react": "^0.566.0",
|
"lucide-react": "^0.566.0",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"punycode": "^2.3.1",
|
"punycode": "^2.3.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-chartjs-2": "^5.3.0",
|
||||||
|
"react-confetti-explosion": "^3.0.3",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-ga4": "^2.1.0",
|
"react-ga4": "^2.1.0",
|
||||||
@@ -96,6 +105,7 @@
|
|||||||
"@faker-js/faker": "^9.5.1",
|
"@faker-js/faker": "^9.5.1",
|
||||||
"@types/chroma-js": "^3.1.1",
|
"@types/chroma-js": "^3.1.1",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@playwright/test": "^1.52.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
|
|||||||
96
postbuild.js
Normal file
@@ -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(
|
||||||
|
/<script(?![^>]*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,
|
||||||
|
`<script src="/assets/${chunkFileName}" crossorigin=""></script>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write updated HTML file
|
||||||
|
writeFileSync(file, updatedFile, "utf8");
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Post-build script completed successfully!");
|
||||||
8
src/app/(dashboard)/(cloud)/customers/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { globalMetaTitle } from "@utils/meta";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import BlankLayout from "@/layouts/BlankLayout";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `Customers - ${globalMetaTitle}`,
|
||||||
|
};
|
||||||
|
export default BlankLayout;
|
||||||
69
src/app/(dashboard)/(cloud)/customers/page.tsx
Normal file
@@ -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 <FullScreenLoading fullScreen={false} />;
|
||||||
|
return <CustomersPageContent />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomersPageContent = () => {
|
||||||
|
const { permission } = usePermissions();
|
||||||
|
const { data: customers, isLoading } = useFetchApi<DistributorCustomer[]>(
|
||||||
|
"/integrations/msp/reseller/msps",
|
||||||
|
);
|
||||||
|
const { ref: headingRef, portalTarget } =
|
||||||
|
usePortalElement<HTMLHeadingElement>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className={"p-default py-6"}>
|
||||||
|
<Breadcrumbs>
|
||||||
|
<Breadcrumbs.Item
|
||||||
|
href={"/customers"}
|
||||||
|
label={"Customers"}
|
||||||
|
icon={<MSPIcon size={15} />}
|
||||||
|
/>
|
||||||
|
</Breadcrumbs>
|
||||||
|
<h1 ref={headingRef}>Customers</h1>
|
||||||
|
<Paragraph>
|
||||||
|
Use this view to manage customer accounts and their plans.
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
<DistributorDocsLink />
|
||||||
|
in our documentation.
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
<RestrictedAccess
|
||||||
|
page={"Customers"}
|
||||||
|
hasAccess={permission.tenants.create}
|
||||||
|
>
|
||||||
|
<Suspense fallback={<SkeletonTable />}>
|
||||||
|
<CustomersProvider>
|
||||||
|
<DistributorCustomersTable
|
||||||
|
isLoading={isLoading}
|
||||||
|
headingTarget={portalTarget}
|
||||||
|
customers={customers}
|
||||||
|
/>
|
||||||
|
</CustomersProvider>
|
||||||
|
</Suspense>
|
||||||
|
</RestrictedAccess>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
8
src/app/(dashboard)/(cloud)/integrations/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { globalMetaTitle } from "@utils/meta";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import BlankLayout from "@/layouts/BlankLayout";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `Integrations - ${globalMetaTitle}`,
|
||||||
|
};
|
||||||
|
export default BlankLayout;
|
||||||
73
src/app/(dashboard)/(cloud)/integrations/page.tsx
Normal file
@@ -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 (
|
||||||
|
<PageContainer>
|
||||||
|
<VerticalTabs value={tab} onChange={setTab}>
|
||||||
|
<VerticalTabs.List>
|
||||||
|
<VerticalTabs.Trigger value="identity-provider">
|
||||||
|
<FingerprintIcon size={14} />
|
||||||
|
Identity Provider Sync
|
||||||
|
</VerticalTabs.Trigger>
|
||||||
|
|
||||||
|
{isNetBirdCloud() && (
|
||||||
|
<VerticalTabs.Trigger value="sso">
|
||||||
|
<KeyRoundIcon size={14} />
|
||||||
|
Single Sign-On
|
||||||
|
</VerticalTabs.Trigger>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VerticalTabs.Trigger value="event-streaming">
|
||||||
|
<FileText size={14} />
|
||||||
|
Event Streaming
|
||||||
|
</VerticalTabs.Trigger>
|
||||||
|
<VerticalTabs.Trigger value="edr">
|
||||||
|
<ShieldCheckIcon size={15} />
|
||||||
|
MDM & EDR
|
||||||
|
</VerticalTabs.Trigger>
|
||||||
|
</VerticalTabs.List>
|
||||||
|
<RestrictedAccess
|
||||||
|
page={"Integrations"}
|
||||||
|
hasAccess={
|
||||||
|
permission?.edr?.read ||
|
||||||
|
permission?.idp?.read ||
|
||||||
|
permission?.event_streaming?.read ||
|
||||||
|
(!isNetBirdCloud() && (permission?.settings?.read ?? false))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||||
|
<IdentityProviderTab />
|
||||||
|
<SSOTab />
|
||||||
|
<EventStreamingTab />
|
||||||
|
{account && <EDRTab account={account} />}
|
||||||
|
</div>
|
||||||
|
</RestrictedAccess>
|
||||||
|
</VerticalTabs>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/app/(dashboard)/(cloud)/msp/page.tsx
Normal file
@@ -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<string>("/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 <Redirect />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal open={open} onOpenChange={setOpen}>
|
||||||
|
<ModalContent
|
||||||
|
maxWidthClass={"max-w-sm relative"}
|
||||||
|
showClose={false}
|
||||||
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
onPointerDownOutside={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<GradientFadedBackground />
|
||||||
|
<div className={"flex items-center justify-center flex-col gap-2 px-8"}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"bg-nb-gray-900 rounded-full h-11 w-11 flex items-center justify-center mb-2"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NetBirdIcon size={24} className={"shrink-0"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"text-xl font-medium text-center max-w-xs mb-1"}>
|
||||||
|
NetBird invites you to join as an Managed Service Provider (MSP)
|
||||||
|
</div>
|
||||||
|
<div className={"text-sm text-nb-gray-300 text-center"}>
|
||||||
|
You will get access to the NetBird MSP portal where you can manage
|
||||||
|
multiple customers and their networks from a single place.
|
||||||
|
</div>
|
||||||
|
{!isOwner && !isMSPAccount && (
|
||||||
|
<Callout
|
||||||
|
icon={
|
||||||
|
<LockIcon size={14} className={"shrink-0 relative top-[3px]"} />
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
{isMSPAccount && !calledOnce && (
|
||||||
|
<Callout className={"text-xs mt-3 w-full"}>
|
||||||
|
The invitation has already been accepted
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter separator={false} className={"gap-x-2 mt-1"}>
|
||||||
|
<Button
|
||||||
|
className={"w-full"}
|
||||||
|
variant={"secondary"}
|
||||||
|
onClick={redirectTo}
|
||||||
|
>
|
||||||
|
{declineButtonText}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
autoFocus={true}
|
||||||
|
className={"w-full"}
|
||||||
|
variant={"primary"}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={acceptInvitation}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Redirect = () => {
|
||||||
|
useRedirect("/peers");
|
||||||
|
return <FullScreenLoading fullScreen={false} />;
|
||||||
|
};
|
||||||
8
src/app/(dashboard)/(cloud)/plans/cancel/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { globalMetaTitle } from "@utils/meta";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import BlankLayout from "@/layouts/BlankLayout";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `Plans - ${globalMetaTitle}`,
|
||||||
|
};
|
||||||
|
export default BlankLayout;
|
||||||
9
src/app/(dashboard)/(cloud)/plans/cancel/page.tsx
Normal file
@@ -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 <FullScreenLoading fullScreen={false} />;
|
||||||
|
}
|
||||||
8
src/app/(dashboard)/(cloud)/plans/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { globalMetaTitle } from "@utils/meta";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import BlankLayout from "@/layouts/BlankLayout";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `Plans - ${globalMetaTitle}`,
|
||||||
|
};
|
||||||
|
export default BlankLayout;
|
||||||
10
src/app/(dashboard)/(cloud)/plans/page.tsx
Normal file
@@ -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 <FullScreenLoading fullScreen={false} />;
|
||||||
|
}
|
||||||
8
src/app/(dashboard)/(cloud)/plans/success/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { globalMetaTitle } from "@utils/meta";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import BlankLayout from "@/layouts/BlankLayout";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `Plans - ${globalMetaTitle}`,
|
||||||
|
};
|
||||||
|
export default BlankLayout;
|
||||||
10
src/app/(dashboard)/(cloud)/plans/success/page.tsx
Normal file
@@ -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 <FullScreenLoading fullScreen={false} />;
|
||||||
|
}
|
||||||
8
src/app/(dashboard)/(cloud)/tenants/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { globalMetaTitle } from "@utils/meta";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import BlankLayout from "@/layouts/BlankLayout";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `Tenants - ${globalMetaTitle}`,
|
||||||
|
};
|
||||||
|
export default BlankLayout;
|
||||||
85
src/app/(dashboard)/(cloud)/tenants/page.tsx
Normal file
@@ -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 <FullScreenLoading fullScreen={false} />;
|
||||||
|
if (!show) return <Redirect />;
|
||||||
|
return <TenantsPageContent />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Redirect = () => {
|
||||||
|
useRedirect("/peers");
|
||||||
|
return <FullScreenLoading fullScreen={false} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TenantsPageContent = () => {
|
||||||
|
const { permission } = usePermissions();
|
||||||
|
const { data: tenants, isLoading } = useFetchApi<Tenant[]>(
|
||||||
|
"/integrations/msp/tenants",
|
||||||
|
);
|
||||||
|
const { ref: headingRef, portalTarget } =
|
||||||
|
usePortalElement<HTMLHeadingElement>();
|
||||||
|
|
||||||
|
useFetchApi<User[]>("/users", true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className={"p-default py-6"}>
|
||||||
|
<Breadcrumbs>
|
||||||
|
<Breadcrumbs.Item
|
||||||
|
href={"/tenants"}
|
||||||
|
label={"Tenants"}
|
||||||
|
icon={<MSPIcon size={15} />}
|
||||||
|
/>
|
||||||
|
</Breadcrumbs>
|
||||||
|
<h1 ref={headingRef}>Tenants</h1>
|
||||||
|
<Paragraph>
|
||||||
|
A list of all tenants and their subscription details. Use this view to
|
||||||
|
manage accounts, plans and permissions.
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
<MSPTenantDocsLink />
|
||||||
|
in our documentation.
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
<RestrictedAccess page={"Tenants"} hasAccess={permission.tenants.read}>
|
||||||
|
<Suspense fallback={<SkeletonTable />}>
|
||||||
|
<TenantsProvider>
|
||||||
|
<MSPTenantsTable
|
||||||
|
isLoading={isLoading}
|
||||||
|
headingTarget={portalTarget}
|
||||||
|
tenants={tenants}
|
||||||
|
/>
|
||||||
|
</TenantsProvider>
|
||||||
|
</Suspense>
|
||||||
|
</RestrictedAccess>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,10 +4,7 @@ import "@xyflow/react/dist/style.css";
|
|||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
import InlineLink from "@components/InlineLink";
|
import InlineLink from "@components/InlineLink";
|
||||||
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
|
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
|
||||||
import {
|
import { SelectDropdown, SelectOption } from "@components/select/SelectDropdown";
|
||||||
SelectDropdown,
|
|
||||||
SelectOption,
|
|
||||||
} from "@components/select/SelectDropdown";
|
|
||||||
import SquareIcon from "@components/SquareIcon";
|
import SquareIcon from "@components/SquareIcon";
|
||||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||||
@@ -22,16 +19,10 @@ import {
|
|||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
useReactFlow,
|
useReactFlow
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { forEach, orderBy, sortBy } from "lodash";
|
import { forEach, orderBy, sortBy } from "lodash";
|
||||||
import {
|
import { ArrowLeftIcon, ExternalLinkIcon, LayoutGridIcon, MessageSquareShareIcon, NetworkIcon } from "lucide-react";
|
||||||
ArrowLeftIcon,
|
|
||||||
ExternalLinkIcon,
|
|
||||||
LayoutGridIcon,
|
|
||||||
MessageSquareShareIcon,
|
|
||||||
NetworkIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||||
@@ -53,13 +44,13 @@ import { EDGE_TYPES } from "@/modules/control-center/utils/edges";
|
|||||||
import {
|
import {
|
||||||
getFirstGroup,
|
getFirstGroup,
|
||||||
getPolicyProtocolAndPortText,
|
getPolicyProtocolAndPortText,
|
||||||
getResourcePolicyByGroups,
|
getResourcePolicyByGroups
|
||||||
} from "@/modules/control-center/utils/helpers";
|
} from "@/modules/control-center/utils/helpers";
|
||||||
import {
|
import {
|
||||||
applyD3ForceLayout,
|
applyD3ForceLayout,
|
||||||
applyD3HierarchicalLayout,
|
applyD3HierarchicalLayout,
|
||||||
DEFAULT_MAX_ZOOM,
|
DEFAULT_MAX_ZOOM,
|
||||||
DEFAULT_MIN_ZOOM,
|
DEFAULT_MIN_ZOOM
|
||||||
} from "@/modules/control-center/utils/layouts";
|
} from "@/modules/control-center/utils/layouts";
|
||||||
import { NODE_TYPES } from "@/modules/control-center/utils/nodes";
|
import { NODE_TYPES } from "@/modules/control-center/utils/nodes";
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ const SettingDisabledManagementGroups = ({
|
|||||||
Peers in these groups will require manual domain name resolution
|
Peers in these groups will require manual domain name resolution
|
||||||
</HelpText>
|
</HelpText>
|
||||||
<PeerGroupSelector
|
<PeerGroupSelector
|
||||||
dataCy={"dns-groups-selector"}
|
data-testid={"dns-groups-selector"}
|
||||||
onChange={setSelectedGroups}
|
onChange={setSelectedGroups}
|
||||||
values={selectedGroups}
|
values={selectedGroups}
|
||||||
disabled={!permission.dns.update}
|
disabled={!permission.dns.update}
|
||||||
@@ -139,7 +139,7 @@ const SettingDisabledManagementGroups = ({
|
|||||||
size={"sm"}
|
size={"sm"}
|
||||||
onClick={saveSettings}
|
onClick={saveSettings}
|
||||||
disabled={!hasChanges || !permission.dns.update}
|
disabled={!hasChanges || !permission.dns.update}
|
||||||
data-cy={"save-changes"}
|
data-testid={"save-changes"}
|
||||||
>
|
>
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
|||||||
import { ActivityEvent } from "@/interfaces/ActivityEvent";
|
import { ActivityEvent } from "@/interfaces/ActivityEvent";
|
||||||
import PageContainer from "@/layouts/PageContainer";
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
import ActivityTable from "@/modules/activity/ActivityTable";
|
import ActivityTable from "@/modules/activity/ActivityTable";
|
||||||
|
import { EventStreamingCard } from "@/modules/integrations/event-streaming/EventStreamingCard";
|
||||||
|
|
||||||
export default function Activity() {
|
export default function Activity() {
|
||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
@@ -52,6 +53,7 @@ export default function Activity() {
|
|||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
<RestrictedAccess page={"Activity"} hasAccess={permission.events.read}>
|
<RestrictedAccess page={"Activity"} hasAccess={permission.events.read}>
|
||||||
|
<EventStreamingCard />
|
||||||
<ActivityTable
|
<ActivityTable
|
||||||
events={events}
|
events={events}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
8
src/app/(dashboard)/events/traffic/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { globalMetaTitle } from "@utils/meta";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import BlankLayout from "@/layouts/BlankLayout";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `Traffic Events - Activity - ${globalMetaTitle}`,
|
||||||
|
};
|
||||||
|
export default BlankLayout;
|
||||||
414
src/app/(dashboard)/events/traffic/page.tsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@components/Breadcrumbs";
|
||||||
|
import InlineLink from "@components/InlineLink";
|
||||||
|
import Paragraph from "@components/Paragraph";
|
||||||
|
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||||
|
import useFetchApi from "@utils/api";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { ArrowLeftRightIcon, ExternalLinkIcon } from "lucide-react";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { DateRange } from "react-day-picker";
|
||||||
|
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
||||||
|
import { useIsFeatureLocked } from "@/cloud/cloud-hooks/useIsFeatureLocked";
|
||||||
|
import { TrafficEvent } from "@/cloud/traffic-events/interfaces/TrafficEvent";
|
||||||
|
import { TRAFFIC_EVENTS_DOC_LINK } from "@/cloud/traffic-events/TrafficEventSetting";
|
||||||
|
import TrafficEventsTable from "@/cloud/traffic-events/TrafficEventsTable";
|
||||||
|
import PeersProvider from "@/contexts/PeersProvider";
|
||||||
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
|
import { useUsers } from "@/contexts/UsersProvider";
|
||||||
|
import { Pagination } from "@/interfaces/Pagination";
|
||||||
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
|
import { useAccount } from "@/modules/account/useAccount";
|
||||||
|
import { LockedFeatureInfoCard } from "@/modules/billing/locked-feature/LockedFeatureInfoCard";
|
||||||
|
import { LockedFeatureOverlay } from "@/modules/billing/locked-feature/LockedFeatureOverlay";
|
||||||
|
import { EventStreamingCard } from "@/modules/integrations/event-streaming/EventStreamingCard";
|
||||||
|
|
||||||
|
const DEFAULT_PAGE = "1";
|
||||||
|
const DEFAULT_PAGE_SIZE = "10";
|
||||||
|
|
||||||
|
export default function NetworkTrafficPage() {
|
||||||
|
const account = useAccount();
|
||||||
|
const { permission } = usePermissions();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { users } = useUsers();
|
||||||
|
|
||||||
|
const currentPage = searchParams.get("page") || DEFAULT_PAGE;
|
||||||
|
const currentPageSize = searchParams.get("page_size") || DEFAULT_PAGE_SIZE;
|
||||||
|
const searchTerm = searchParams.get("search") || "";
|
||||||
|
const dateFrom = searchParams.get("start_date");
|
||||||
|
const dateTo = searchParams.get("end_date");
|
||||||
|
const userId = searchParams.get("user_id") || "";
|
||||||
|
|
||||||
|
const typeFilter = searchParams.getAll("type") || [];
|
||||||
|
const connectionTypeFilter = searchParams.get("connection_type") || "";
|
||||||
|
const directionFilter = searchParams.getAll("direction") || [];
|
||||||
|
const protocolFilter = searchParams.getAll("protocol") || [];
|
||||||
|
|
||||||
|
const formattedDateFrom = dateFrom ? encodeURIComponent(dateFrom) : "";
|
||||||
|
const formattedDateTo = dateTo ? encodeURIComponent(dateTo) : "";
|
||||||
|
const formattedUserId = userId ? encodeURIComponent(userId) : "";
|
||||||
|
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange | undefined>(() => {
|
||||||
|
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<Pagination<TrafficEvent[]>>(
|
||||||
|
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<string, string | string[]>) => {
|
||||||
|
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<string, string> = {};
|
||||||
|
|
||||||
|
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<string, string | string[]> = {};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="p-default py-6">
|
||||||
|
<Breadcrumbs>
|
||||||
|
<Breadcrumbs.Item
|
||||||
|
label="Activity"
|
||||||
|
disabled
|
||||||
|
icon={<ActivityIcon size={13} />}
|
||||||
|
/>
|
||||||
|
<Breadcrumbs.Item
|
||||||
|
href="/events/traffic"
|
||||||
|
label="Traffic Events"
|
||||||
|
icon={<ArrowLeftRightIcon size={15} />}
|
||||||
|
/>
|
||||||
|
</Breadcrumbs>
|
||||||
|
|
||||||
|
<h1>{`${events?.total_records ?? 0}`} Traffic Events</h1>
|
||||||
|
|
||||||
|
<Paragraph>
|
||||||
|
Traffic events is an experimental feature. Functionality and behavior
|
||||||
|
may evolve, including changes to how data is collected or reported.
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Paragraph>
|
||||||
|
Learn more about{" "}
|
||||||
|
<InlineLink href={TRAFFIC_EVENTS_DOC_LINK} target="_blank">
|
||||||
|
Traffic Events <ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>{" "}
|
||||||
|
in our documentation.
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RestrictedAccess
|
||||||
|
page="Traffic Events"
|
||||||
|
hasAccess={permission.events.read}
|
||||||
|
>
|
||||||
|
<div className={"p-default"}>
|
||||||
|
<LockedFeatureInfoCard
|
||||||
|
className={"mb-6"}
|
||||||
|
feature={"TRAFFIC_EVENTS"}
|
||||||
|
featureText={"Traffic Events"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LockedFeatureOverlay feature={"TRAFFIC_EVENTS"}>
|
||||||
|
<EventStreamingCard />
|
||||||
|
<PeersProvider>
|
||||||
|
<TrafficEventsTable
|
||||||
|
events={trafficEvents}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isSettingEnabled={isEnabled}
|
||||||
|
totalRecords={events?.total_records}
|
||||||
|
totalPages={events?.total_pages}
|
||||||
|
onPaginationChange={handlePaginationChange}
|
||||||
|
pagination={pagination}
|
||||||
|
onGlobalFilterChange={handleGlobalFilterChange}
|
||||||
|
globalFilter={searchTerm}
|
||||||
|
onDateFilterChange={handleDateFilterChange}
|
||||||
|
dateFrom={dateFrom || ""}
|
||||||
|
dateTo={dateTo || ""}
|
||||||
|
apiUrl={apiUrl}
|
||||||
|
filters={tableFilters}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
users={users}
|
||||||
|
userId={userId}
|
||||||
|
onUserFilterChange={handleUserFilterChange}
|
||||||
|
onResetAllFilters={handleResetAllFilters}
|
||||||
|
connectionTypeFilter={connectionTypeFilter}
|
||||||
|
onConnectionTypeFilterChange={handleConnectionTypeFilterChange}
|
||||||
|
/>
|
||||||
|
</PeersProvider>
|
||||||
|
</LockedFeatureOverlay>
|
||||||
|
</RestrictedAccess>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -180,6 +180,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
|||||||
{group.name !== "All" && (
|
{group.name !== "All" && (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value={"users"}
|
value={"users"}
|
||||||
|
data-testid="group-tab-users"
|
||||||
className={groupDetails === null ? "animate-pulse" : ""}
|
className={groupDetails === null ? "animate-pulse" : ""}
|
||||||
>
|
>
|
||||||
<TeamIcon
|
<TeamIcon
|
||||||
@@ -195,6 +196,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
|||||||
{group.name !== "All" && (
|
{group.name !== "All" && (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value={"peers"}
|
value={"peers"}
|
||||||
|
data-testid="group-tab-peers"
|
||||||
className={groupDetails === null ? "animate-pulse" : ""}
|
className={groupDetails === null ? "animate-pulse" : ""}
|
||||||
>
|
>
|
||||||
<PeerIcon
|
<PeerIcon
|
||||||
@@ -209,6 +211,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
|||||||
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value={"policies"}
|
value={"policies"}
|
||||||
|
data-testid="group-tab-policies"
|
||||||
className={groupDetails === null ? "animate-pulse" : ""}
|
className={groupDetails === null ? "animate-pulse" : ""}
|
||||||
>
|
>
|
||||||
<AccessControlIcon
|
<AccessControlIcon
|
||||||
@@ -222,6 +225,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
|||||||
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value={"resources"}
|
value={"resources"}
|
||||||
|
data-testid="group-tab-resources"
|
||||||
className={groupDetails === null ? "animate-pulse" : ""}
|
className={groupDetails === null ? "animate-pulse" : ""}
|
||||||
>
|
>
|
||||||
<Layers3Icon size={14} />
|
<Layers3Icon size={14} />
|
||||||
@@ -230,6 +234,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
|||||||
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value={"network-routes"}
|
value={"network-routes"}
|
||||||
|
data-testid="group-tab-network-routes"
|
||||||
className={groupDetails === null ? "animate-pulse" : ""}
|
className={groupDetails === null ? "animate-pulse" : ""}
|
||||||
>
|
>
|
||||||
<NetworkRoutesIcon
|
<NetworkRoutesIcon
|
||||||
@@ -243,6 +248,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
|||||||
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value={"nameservers"}
|
value={"nameservers"}
|
||||||
|
data-testid="group-tab-nameservers"
|
||||||
className={groupDetails === null ? "animate-pulse" : ""}
|
className={groupDetails === null ? "animate-pulse" : ""}
|
||||||
>
|
>
|
||||||
<DNSIcon
|
<DNSIcon
|
||||||
@@ -256,6 +262,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
|||||||
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value={"zones"}
|
value={"zones"}
|
||||||
|
data-testid="group-tab-zones"
|
||||||
className={groupDetails === null ? "animate-pulse" : ""}
|
className={groupDetails === null ? "animate-pulse" : ""}
|
||||||
>
|
>
|
||||||
<DNSZoneIcon
|
<DNSZoneIcon
|
||||||
@@ -270,6 +277,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
|||||||
{group.name !== "All" && (
|
{group.name !== "All" && (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value={"setup-keys"}
|
value={"setup-keys"}
|
||||||
|
data-testid="group-tab-setup-keys"
|
||||||
className={groupDetails === null ? "animate-pulse" : ""}
|
className={groupDetails === null ? "animate-pulse" : ""}
|
||||||
>
|
>
|
||||||
<SetupKeysIcon
|
<SetupKeysIcon
|
||||||
|
|||||||
@@ -147,11 +147,14 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
|||||||
className={"pb-0 mb-0"}
|
className={"pb-0 mb-0"}
|
||||||
>
|
>
|
||||||
<TabsList justify={"start"} className={"px-8"}>
|
<TabsList justify={"start"} className={"px-8"}>
|
||||||
<TabsTrigger value={"resources"}>
|
<TabsTrigger value={"resources"} data-testid="network-tab-resources">
|
||||||
<Layers3Icon size={14} />
|
<Layers3Icon size={14} />
|
||||||
{singularize("Resources", network?.resources?.length)}
|
{singularize("Resources", network?.resources?.length)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value={"routing-peers"}>
|
<TabsTrigger
|
||||||
|
value={"routing-peers"}
|
||||||
|
data-testid="network-tab-routing-peers"
|
||||||
|
>
|
||||||
<PeerIcon
|
<PeerIcon
|
||||||
size={12}
|
size={12}
|
||||||
className={
|
className={
|
||||||
@@ -160,7 +163,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
|||||||
/>
|
/>
|
||||||
{singularize("Routing Peers", network?.routing_peers_count)}
|
{singularize("Routing Peers", network?.routing_peers_count)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value={"services"}>
|
<TabsTrigger value={"services"} data-testid="network-tab-services">
|
||||||
<ReverseProxyIcon
|
<ReverseProxyIcon
|
||||||
size={16}
|
size={16}
|
||||||
className={
|
className={
|
||||||
@@ -214,7 +217,7 @@ function NetworkActions() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button variant={"secondary"} className={"!px-3"}>
|
<Button variant={"secondary"} className={"!px-3"} data-testid="network-detail-actions">
|
||||||
<MoreVertical size={16} className={"shrink-0"} />
|
<MoreVertical size={16} className={"shrink-0"} />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -222,6 +225,7 @@ function NetworkActions() {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => openEditNetworkModal(network)}
|
onClick={() => openEditNetworkModal(network)}
|
||||||
disabled={!permission.networks.update}
|
disabled={!permission.networks.update}
|
||||||
|
data-testid="rename-network"
|
||||||
>
|
>
|
||||||
<div className={"flex gap-3 items-center"}>
|
<div className={"flex gap-3 items-center"}>
|
||||||
<PencilLineIcon size={14} className={"shrink-0"} />
|
<PencilLineIcon size={14} className={"shrink-0"} />
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ import { toASCII } from "punycode";
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import Skeleton from "react-loading-skeleton";
|
import Skeleton from "react-loading-skeleton";
|
||||||
import { useSWRConfig } from "swr";
|
import { useSWRConfig } from "swr";
|
||||||
|
import {
|
||||||
|
TrafficEventsPeerTabContent,
|
||||||
|
TrafficEventsPeerTabTrigger,
|
||||||
|
} from "@/cloud/traffic-events/TrafficEventsPeerTabContent";
|
||||||
import RoundedFlag from "@/assets/countries/RoundedFlag";
|
import RoundedFlag from "@/assets/countries/RoundedFlag";
|
||||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||||
@@ -361,7 +365,7 @@ const PeerOverviewTabs = () => {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{peer?.id && permission.peers.read && (
|
{peer?.id && (
|
||||||
<TabsTrigger value={"accessible-peers"}>
|
<TabsTrigger value={"accessible-peers"}>
|
||||||
<MonitorSmartphoneIcon size={16} />
|
<MonitorSmartphoneIcon size={16} />
|
||||||
Accessible Peers
|
Accessible Peers
|
||||||
@@ -384,6 +388,8 @@ const PeerOverviewTabs = () => {
|
|||||||
Remote Jobs
|
Remote Jobs
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{permission.events.read && <TrafficEventsPeerTabTrigger />}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value={"overview"} className={"pb-8"}>
|
<TabsContent value={"overview"} className={"pb-8"}>
|
||||||
@@ -396,7 +402,7 @@ const PeerOverviewTabs = () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{peer?.id && permission.peers.read && (
|
{peer?.id && (
|
||||||
<TabsContent value={"accessible-peers"} className={"pb-8"}>
|
<TabsContent value={"accessible-peers"} className={"pb-8"}>
|
||||||
<AccessiblePeersSection peerID={peer.id} />
|
<AccessiblePeersSection peerID={peer.id} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -421,6 +427,12 @@ const PeerOverviewTabs = () => {
|
|||||||
<PeerRemoteJobsSection peerID={peer.id} />
|
<PeerRemoteJobsSection peerID={peer.id} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{permission.events.read && (
|
||||||
|
<TabsContent value={"traffic-events"} className={"pb-8"}>
|
||||||
|
<TrafficEventsPeerTabContent />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
export default function PeersIndex() {
|
export default function PeersIndex() {
|
||||||
redirect("/peers/users");
|
redirect("/peers/users");
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,9 @@ import { usePortalElement } from "@hooks/usePortalElement";
|
|||||||
import { ExternalLinkIcon } from "lucide-react";
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
import React, { lazy, Suspense, useMemo } from "react";
|
import React, { lazy, Suspense, useMemo } from "react";
|
||||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
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 PeersProvider, { usePeers } from "@/contexts/PeersProvider";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useUsers } from "@/contexts/UsersProvider";
|
import { useUsers } from "@/contexts/UsersProvider";
|
||||||
@@ -18,6 +21,8 @@ const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
|
|||||||
|
|
||||||
export default function ServersPage() {
|
export default function ServersPage() {
|
||||||
const { isRestricted } = usePermissions();
|
const { isRestricted } = usePermissions();
|
||||||
|
const { isLoading: isDistributorRedirecting } = useDistributorRedirect();
|
||||||
|
if (isDistributorRedirecting) return <FullScreenLoading />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -35,6 +40,7 @@ export default function ServersPage() {
|
|||||||
function ServersView() {
|
function ServersView() {
|
||||||
const { peers, isLoading: isPeersLoading } = usePeers();
|
const { peers, isLoading: isPeersLoading } = usePeers();
|
||||||
const { users, isLoading: isUsersLoading } = useUsers();
|
const { users, isLoading: isUsersLoading } = useUsers();
|
||||||
|
const { isBypassed } = useBypassedPeers();
|
||||||
const { ref: headingRef, portalTarget } =
|
const { ref: headingRef, portalTarget } =
|
||||||
usePortalElement<HTMLHeadingElement>();
|
usePortalElement<HTMLHeadingElement>();
|
||||||
|
|
||||||
@@ -48,8 +54,9 @@ function ServersView() {
|
|||||||
return peers.map((peer) => ({
|
return peers.map((peer) => ({
|
||||||
...peer,
|
...peer,
|
||||||
user: users.find((u) => u.id === peer.user_id),
|
user: users.find((u) => u.id === peer.user_id),
|
||||||
|
force_approved: peer.id ? isBypassed(peer.id) : false,
|
||||||
}));
|
}));
|
||||||
}, [peers, users]);
|
}, [peers, users, isBypassed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { usePortalElement } from "@hooks/usePortalElement";
|
|||||||
import { ExternalLinkIcon } from "lucide-react";
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
import React, { lazy, Suspense, useMemo } from "react";
|
import React, { lazy, Suspense, useMemo } from "react";
|
||||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
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 PeersProvider, { usePeers } from "@/contexts/PeersProvider";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useUsers } from "@/contexts/UsersProvider";
|
import { useUsers } from "@/contexts/UsersProvider";
|
||||||
@@ -18,6 +21,8 @@ const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
|
|||||||
|
|
||||||
export default function UserDevicesPage() {
|
export default function UserDevicesPage() {
|
||||||
const { isRestricted } = usePermissions();
|
const { isRestricted } = usePermissions();
|
||||||
|
const { isLoading: isDistributorRedirecting } = useDistributorRedirect();
|
||||||
|
if (isDistributorRedirecting) return <FullScreenLoading />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -35,6 +40,7 @@ export default function UserDevicesPage() {
|
|||||||
function UserDevicesView() {
|
function UserDevicesView() {
|
||||||
const { peers, isLoading: isPeersLoading } = usePeers();
|
const { peers, isLoading: isPeersLoading } = usePeers();
|
||||||
const { users, isLoading: isUsersLoading } = useUsers();
|
const { users, isLoading: isUsersLoading } = useUsers();
|
||||||
|
const { isBypassed } = useBypassedPeers();
|
||||||
const { ref: headingRef, portalTarget } =
|
const { ref: headingRef, portalTarget } =
|
||||||
usePortalElement<HTMLHeadingElement>();
|
usePortalElement<HTMLHeadingElement>();
|
||||||
|
|
||||||
@@ -48,8 +54,9 @@ function UserDevicesView() {
|
|||||||
return peers.map((peer) => ({
|
return peers.map((peer) => ({
|
||||||
...peer,
|
...peer,
|
||||||
user: users.find((u) => u.id === peer.user_id),
|
user: users.find((u) => u.id === peer.user_id),
|
||||||
|
force_approved: peer.id ? isBypassed(peer.id) : false,
|
||||||
}));
|
}));
|
||||||
}, [peers, users]);
|
}, [peers, users, isBypassed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
|
|||||||
import { REVERSE_PROXY_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
import { REVERSE_PROXY_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||||
import PageContainer from "@/layouts/PageContainer";
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
import { Callout } from "@components/Callout";
|
import { Callout } from "@components/Callout";
|
||||||
import { isNetBirdHosted } from "@utils/netbird";
|
import { isNetBirdCloud } from "@utils/netbird";
|
||||||
|
|
||||||
const ReverseProxyTable = lazy(
|
const ReverseProxyTable = lazy(
|
||||||
() => import("@/modules/reverse-proxy/table/ReverseProxyTable"),
|
() => import("@/modules/reverse-proxy/table/ReverseProxyTable"),
|
||||||
@@ -50,7 +50,7 @@ export default function ReverseProxyServicesPage() {
|
|||||||
</InlineLink>
|
</InlineLink>
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
{isNetBirdHosted() ? (
|
{isNetBirdCloud() ? (
|
||||||
<Callout className={"max-w-xl mt-5"} variant={"info"}>
|
<Callout className={"max-w-xl mt-5"} variant={"info"}>
|
||||||
NetBird's Reverse Proxy is currently in beta and available at
|
NetBird's Reverse Proxy is currently in beta and available at
|
||||||
no cost during this period. Features, functionality, and pricing are
|
no cost during this period. Features, functionality, and pricing are
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMSP } from "@/cloud/msp/contexts/MSPProvider";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||||
import PageContainer from "@/layouts/PageContainer";
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
@@ -26,6 +27,10 @@ import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
|||||||
import PermissionsTab from "@/modules/settings/PermissionsTab";
|
import PermissionsTab from "@/modules/settings/PermissionsTab";
|
||||||
import SetupKeysTab from "@/modules/settings/SetupKeysTab";
|
import SetupKeysTab from "@/modules/settings/SetupKeysTab";
|
||||||
import GroupsSettings from "@/modules/settings/GroupsSettings";
|
import GroupsSettings from "@/modules/settings/GroupsSettings";
|
||||||
|
import {
|
||||||
|
CloudSettingsTabContent,
|
||||||
|
CloudSettingsTabTrigger,
|
||||||
|
} from "@/cloud/settings/CloudSettings";
|
||||||
|
|
||||||
export default function NetBirdSettings() {
|
export default function NetBirdSettings() {
|
||||||
const queryParams = useSearchParams();
|
const queryParams = useSearchParams();
|
||||||
@@ -33,7 +38,8 @@ export default function NetBirdSettings() {
|
|||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
|
|
||||||
const initialTab = useMemo(() => {
|
const initialTab = useMemo(() => {
|
||||||
if (permission.settings.read) return "authentication";
|
if (permission?.settings?.read) return "authentication";
|
||||||
|
if (permission?.billing?.update) return "plans-and-billing";
|
||||||
return "authentication";
|
return "authentication";
|
||||||
}, [permission]);
|
}, [permission]);
|
||||||
|
|
||||||
@@ -53,7 +59,7 @@ export default function NetBirdSettings() {
|
|||||||
<VerticalTabs.List>
|
<VerticalTabs.List>
|
||||||
{permission.settings.read && (
|
{permission.settings.read && (
|
||||||
<>
|
<>
|
||||||
<VerticalTabs.Trigger value="authentication">
|
<VerticalTabs.Trigger value="authentication" data-testid="settings-tab-authentication">
|
||||||
<ShieldIcon size={14} />
|
<ShieldIcon size={14} />
|
||||||
Authentication
|
Authentication
|
||||||
</VerticalTabs.Trigger>
|
</VerticalTabs.Trigger>
|
||||||
@@ -70,41 +76,42 @@ export default function NetBirdSettings() {
|
|||||||
Identity Providers
|
Identity Providers
|
||||||
</VerticalTabs.Trigger>
|
</VerticalTabs.Trigger>
|
||||||
)}
|
)}
|
||||||
<VerticalTabs.Trigger value="groups">
|
<VerticalTabs.Trigger value="groups" data-testid="settings-tab-groups">
|
||||||
<FolderGit2Icon size={14} />
|
<FolderGit2Icon size={14} />
|
||||||
Groups
|
Groups
|
||||||
</VerticalTabs.Trigger>
|
</VerticalTabs.Trigger>
|
||||||
<VerticalTabs.Trigger value="permissions">
|
<VerticalTabs.Trigger value="permissions" data-testid="settings-tab-permissions">
|
||||||
<LockIcon size={14} />
|
<LockIcon size={14} />
|
||||||
Permissions
|
Permissions
|
||||||
</VerticalTabs.Trigger>
|
</VerticalTabs.Trigger>
|
||||||
<VerticalTabs.Trigger value="networks">
|
<VerticalTabs.Trigger value="networks" data-testid="settings-tab-networks">
|
||||||
<NetworkIcon size={14} />
|
<NetworkIcon size={14} />
|
||||||
Networks
|
Networks
|
||||||
</VerticalTabs.Trigger>
|
</VerticalTabs.Trigger>
|
||||||
<VerticalTabs.Trigger value="clients">
|
<VerticalTabs.Trigger value="clients" data-testid="settings-tab-clients">
|
||||||
<MonitorSmartphoneIcon size={14} />
|
<MonitorSmartphoneIcon size={14} />
|
||||||
Clients
|
Clients
|
||||||
</VerticalTabs.Trigger>
|
</VerticalTabs.Trigger>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<CloudSettingsTabTrigger />
|
||||||
<DangerZoneTabTrigger />
|
<DangerZoneTabTrigger />
|
||||||
</VerticalTabs.List>
|
</VerticalTabs.List>
|
||||||
<RestrictedAccess
|
<RestrictedAccess
|
||||||
page={"Settings"}
|
page={"Settings"}
|
||||||
hasAccess={permission.settings.read}
|
hasAccess={permission?.billing?.read || permission?.settings?.read}
|
||||||
>
|
>
|
||||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||||
{account && <AuthenticationTab account={account} />}
|
{account && <AuthenticationTab account={account} />}
|
||||||
{permission.setup_keys.read && <SetupKeysTab />}
|
{permission.setup_keys.read && <SetupKeysTab />}
|
||||||
{account?.settings?.embedded_idp_enabled &&
|
{account?.settings?.embedded_idp_enabled &&
|
||||||
permission.identity_providers.read && <IdentityProvidersTab />}
|
permission?.identity_providers?.read && <IdentityProvidersTab />}
|
||||||
{account && <PermissionsTab account={account} />}
|
{account && <PermissionsTab account={account} />}
|
||||||
{account && <GroupsSettings account={account} />}
|
{account && <GroupsSettings account={account} />}
|
||||||
{account && <NetworkSettingsTab account={account} />}
|
{account && <NetworkSettingsTab account={account} />}
|
||||||
{account && <ClientSettingsTab account={account} />}
|
{account && <ClientSettingsTab account={account} />}
|
||||||
{account && <DangerZoneTab account={account} />}
|
{account && <DangerZoneTab account={account} />}
|
||||||
|
<CloudSettingsTabContent />
|
||||||
</div>
|
</div>
|
||||||
</RestrictedAccess>
|
</RestrictedAccess>
|
||||||
</VerticalTabs>
|
</VerticalTabs>
|
||||||
@@ -115,6 +122,9 @@ export default function NetBirdSettings() {
|
|||||||
const DangerZoneTabTrigger = () => {
|
const DangerZoneTabTrigger = () => {
|
||||||
const { isOwner } = useLoggedInUser();
|
const { isOwner } = useLoggedInUser();
|
||||||
|
|
||||||
|
const { isAccountWithMSPParent } = useMSP();
|
||||||
|
if (isAccountWithMSPParent) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
isOwner && (
|
isOwner && (
|
||||||
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
|
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useSWRConfig } from "swr";
|
import { useSWRConfig } from "swr";
|
||||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||||
|
import { UserMfaListItem } from "@/cloud/mfa/UserMFAListItem";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||||
@@ -125,6 +126,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
|||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
mutate(`/users?service_user=${isServiceUser}`);
|
mutate(`/users?service_user=${isServiceUser}`);
|
||||||
|
mutate(`/integrations/msp/switcher`);
|
||||||
updateChangesRef([role, selectedGroups]);
|
updateChangesRef([role, selectedGroups]);
|
||||||
}),
|
}),
|
||||||
loadingMessage: "Saving changes...",
|
loadingMessage: "Saving changes...",
|
||||||
@@ -222,7 +224,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
|||||||
className={"w-full"}
|
className={"w-full"}
|
||||||
disabled={!hasChanges || !permission.users.update}
|
disabled={!hasChanges || !permission.users.update}
|
||||||
onClick={save}
|
onClick={save}
|
||||||
data-cy={"save-changes"}
|
data-testid={"save-changes"}
|
||||||
>
|
>
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
@@ -244,7 +246,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
|||||||
onChange={setSelectedGroups}
|
onChange={setSelectedGroups}
|
||||||
values={selectedGroups}
|
values={selectedGroups}
|
||||||
hideAllGroup={true}
|
hideAllGroup={true}
|
||||||
dataCy={"user-group-selector"}
|
data-testid={"user-group-selector"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -279,13 +281,16 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
|||||||
>
|
>
|
||||||
<TabsList justify={"start"} className={"px-8"} hidden={!showTabs}>
|
<TabsList justify={"start"} className={"px-8"} hidden={!showTabs}>
|
||||||
{showPeers && (
|
{showPeers && (
|
||||||
<TabsTrigger value={"peers"}>
|
<TabsTrigger value={"peers"} data-testid={"user-tab-peers"}>
|
||||||
<MonitorSmartphoneIcon size={16} />
|
<MonitorSmartphoneIcon size={16} />
|
||||||
Peers
|
Peers
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
{showAccessTokens && (
|
{showAccessTokens && (
|
||||||
<TabsTrigger value={"access-tokens"}>
|
<TabsTrigger
|
||||||
|
value={"access-tokens"}
|
||||||
|
data-testid={"user-tab-access-tokens"}
|
||||||
|
>
|
||||||
<KeyRoundIcon size={16} />
|
<KeyRoundIcon size={16} />
|
||||||
Access Tokens
|
Access Tokens
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -312,7 +317,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
|||||||
<CreateAccessTokenModal user={user}>
|
<CreateAccessTokenModal user={user}>
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
data-cy={"access-token-open-modal"}
|
data-testid={"access-token-open-modal"}
|
||||||
disabled={!permission.pats.create}
|
disabled={!permission.pats.create}
|
||||||
>
|
>
|
||||||
<IconCirclePlus size={16} />
|
<IconCirclePlus size={16} />
|
||||||
@@ -375,6 +380,8 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) {
|
|||||||
value={<UserStatusCell user={user} />}
|
value={<UserStatusCell user={user} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!isServiceUser && user && <UserMfaListItem userId={user.id} />}
|
||||||
|
|
||||||
{!isServiceUser && (
|
{!isServiceUser && (
|
||||||
<>
|
<>
|
||||||
{!user.is_current &&
|
{!user.is_current &&
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import useFetchApi from "@utils/api";
|
|||||||
import { ExternalLinkIcon, User2 } from "lucide-react";
|
import { ExternalLinkIcon, User2 } from "lucide-react";
|
||||||
import React, { lazy, Suspense } from "react";
|
import React, { lazy, Suspense } from "react";
|
||||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||||
|
import { AccountMfaCard } from "@/cloud/mfa/AccountMFACard";
|
||||||
import { useGroups } from "@/contexts/GroupsProvider";
|
import { useGroups } from "@/contexts/GroupsProvider";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { User } from "@/interfaces/User";
|
import { User } from "@/interfaces/User";
|
||||||
import PageContainer from "@/layouts/PageContainer";
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
|
import { IdentityProviderCard } from "@/modules/integrations/idp-sync/IdentityProviderCard";
|
||||||
|
|
||||||
const UsersTable = lazy(() => import("@/modules/users/UsersTable"));
|
const UsersTable = lazy(() => import("@/modules/users/UsersTable"));
|
||||||
|
|
||||||
@@ -58,6 +60,12 @@ export default function TeamUsers() {
|
|||||||
</div>
|
</div>
|
||||||
<RestrictedAccess page={"Users"} hasAccess={permission.users.read}>
|
<RestrictedAccess page={"Users"} hasAccess={permission.users.read}>
|
||||||
<Suspense fallback={<SkeletonTable />}>
|
<Suspense fallback={<SkeletonTable />}>
|
||||||
|
{permission.settings.read && (
|
||||||
|
<div className={"flex flex-wrap gap-4 p-default pb-6"}>
|
||||||
|
{permission?.idp?.read && <IdentityProviderCard />}
|
||||||
|
<AccountMfaCard />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<UsersTable
|
<UsersTable
|
||||||
users={users}
|
users={users}
|
||||||
isLoading={isLoading || isGroupsLoading}
|
isLoading={isLoading || isGroupsLoading}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import MSPProvider from "@/cloud/msp/contexts/MSPProvider";
|
||||||
import UsersProvider from "@/contexts/UsersProvider";
|
import UsersProvider from "@/contexts/UsersProvider";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
<MSPProvider>
|
||||||
<UsersProvider>{children}</UsersProvider>
|
<UsersProvider>{children}</UsersProvider>
|
||||||
|
</MSPProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,9 +84,7 @@ function RDPSession({ peer }: Props) {
|
|||||||
try {
|
try {
|
||||||
setCredentials(rdpCredentials);
|
setCredentials(rdpCredentials);
|
||||||
setIsNetBirdConnecting(true);
|
setIsNetBirdConnecting(true);
|
||||||
await client.connectTemporary(peer.id, [
|
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
|
||||||
`tcp/${rdpCredentials.port}`,
|
|
||||||
]);
|
|
||||||
setIsNetBirdConnecting(false);
|
setIsNetBirdConnecting(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendErrorNotification(
|
sendErrorNotification(
|
||||||
|
|||||||
@@ -1,19 +1,53 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Modal } from "@components/modal/Modal";
|
import { Modal } from "@components/modal/Modal";
|
||||||
|
import { AnnouncementBanner } from "@components/ui/AnnouncementBanner";
|
||||||
|
import { useIsMd } from "@utils/responsive";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import AnnouncementProvider, {
|
||||||
|
useAnnouncement,
|
||||||
|
} from "@/contexts/AnnouncementProvider";
|
||||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||||
|
|
||||||
export default function UnauthenticatedInstallModal() {
|
function InstallContent() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const { bannerHeight } = useAnnouncement();
|
||||||
|
const isMd = useIsMd();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal onOpenChange={() => null} open={open}>
|
<>
|
||||||
<SetupModal showClose={false} />
|
{mounted &&
|
||||||
</Modal>
|
createPortal(
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"fixed top-0 left-0 right-0 z-[60] w-full pointer-events-auto"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnnouncementBanner />
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
<Modal onOpenChange={() => null} open={open}>
|
||||||
|
<SetupModal
|
||||||
|
showClose={false}
|
||||||
|
style={{ marginTop: isMd ? 0 : bannerHeight + 13 }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UnauthenticatedInstallModal() {
|
||||||
|
return (
|
||||||
|
<AnnouncementProvider>
|
||||||
|
<InstallContent />
|
||||||
|
</AnnouncementProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ type Props = {
|
|||||||
};
|
};
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [tempQueryParams, setTempQueryParams] = useLocalStorage(
|
const [tempQueryParams, setTempQueryParams] = useLocalStorage<{
|
||||||
"netbird-query-params",
|
path: string;
|
||||||
"",
|
params: string;
|
||||||
);
|
} | null>("netbird-query-params", null);
|
||||||
const [queryParams, setQueryParams] = useState("");
|
const [queryParams, setQueryParams] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQueryParams(tempQueryParams);
|
const currentPath = window.location.pathname || "/";
|
||||||
setTempQueryParams("");
|
if (tempQueryParams?.path === currentPath && tempQueryParams?.params) {
|
||||||
|
setQueryParams(tempQueryParams.params);
|
||||||
|
}
|
||||||
|
setTempQueryParams(null);
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -12,15 +12,18 @@ type Props = {
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [tempQueryParams, setTempQueryParams] = useLocalStorage(
|
const [tempQueryParams, setTempQueryParams] = useLocalStorage<{
|
||||||
"netbird-query-params",
|
path: string;
|
||||||
"",
|
params: string;
|
||||||
);
|
} | null>("netbird-query-params", null);
|
||||||
const [queryParams, setQueryParams] = useState("");
|
const [queryParams, setQueryParams] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQueryParams(tempQueryParams);
|
const currentPath = window.location.pathname || "/";
|
||||||
setTempQueryParams("");
|
if (tempQueryParams?.path === currentPath && tempQueryParams?.params) {
|
||||||
|
setQueryParams(tempQueryParams.params);
|
||||||
|
}
|
||||||
|
setTempQueryParams(null);
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
BIN
src/assets/avatars/jack.jpeg
Normal file
|
After Width: | Height: | Size: 88 KiB |
@@ -16,8 +16,7 @@ export default function CircleIcon({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{ width: size + "px", height: size + "px" }}
|
style={{ width: size + "px", height: size + "px" }}
|
||||||
data-cy="circle-icon"
|
data-testid={`circle-icon-${active ? "active" : "inactive"}`}
|
||||||
data-cy-status={active ? "active" : "inactive"}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full",
|
"rounded-full",
|
||||||
active
|
active
|
||||||
|
|||||||
19
src/assets/icons/MSPIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||||
|
|
||||||
|
export default function MSPIcon(props: Readonly<IconProps>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 20 16"
|
||||||
|
{...iconProperties(props)}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10 1.99999C9.54037 1.99999 9.08525 2.09052 8.66061 2.26641C8.23597 2.4423 7.85013 2.70011 7.52513 3.02511C7.20012 3.35012 6.94231 3.73595 6.76642 4.16059C6.59053 4.58523 6.5 5.04036 6.5 5.49999C6.5 5.95961 6.59053 6.41474 6.76642 6.83938C6.94231 7.26402 7.20012 7.64985 7.52513 7.97486C7.85013 8.29986 8.23597 8.55767 8.66061 8.73356C9.08525 8.90946 9.54037 8.99999 10 8.99999C10.9283 8.99999 11.8185 8.63124 12.4749 7.97486C13.1313 7.31848 13.5 6.42824 13.5 5.49999C13.5 4.57173 13.1313 3.68149 12.4749 3.02511C11.8185 2.36874 10.9283 1.99999 10 1.99999ZM8.5 9.99999C7.43913 9.99999 6.42172 10.4214 5.67157 11.1716C4.92143 11.9217 4.5 12.9391 4.5 14C4.5 14.5304 4.71071 15.0391 5.08579 15.4142C5.46086 15.7893 5.96957 16 6.5 16H13.5C14.0304 16 14.5391 15.7893 14.9142 15.4142C15.2893 15.0391 15.5 14.5304 15.5 14C15.5 12.9391 15.0786 11.9217 14.3284 11.1716C13.5783 10.4214 12.5609 9.99999 11.5 9.99999H8.5ZM15.32 6.90399C15.6379 5.69333 15.5341 4.41051 15.0257 3.2667C14.5174 2.12288 13.6347 1.18625 12.523 0.610986C12.9466 0.321503 13.4287 0.128697 13.9351 0.0462221C14.4415 -0.0362528 14.9599 -0.00637972 15.4535 0.133726C15.947 0.273833 16.4038 0.520745 16.7913 0.856978C17.1789 1.19321 17.4878 1.61054 17.6961 2.07941C17.9045 2.54828 18.0072 3.05723 17.997 3.57021C17.9868 4.08319 17.864 4.58766 17.6372 5.04789C17.4104 5.50812 17.0851 5.91285 16.6845 6.23343C16.2839 6.55401 15.8178 6.78259 15.319 6.90299L15.32 6.90399ZM17.5 14H18C18.5304 14 19.0391 13.7893 19.4142 13.4142C19.7893 13.0391 20 12.5304 20 12C20 10.9391 19.5786 9.9217 18.8284 9.17156C18.0783 8.42141 17.0609 7.99999 16 7.99999H14.9C14.764 8.26636 14.6064 8.52121 14.429 8.76199C15.3606 9.28286 16.1364 10.0429 16.6763 10.9637C17.2161 11.8845 17.5005 12.9326 17.5 14ZM2 3.49999C1.99985 2.86277 2.17366 2.23761 2.50268 1.69192C2.8317 1.14623 3.30346 0.700709 3.86707 0.403417C4.43068 0.106126 5.06476 -0.0316608 5.70092 0.00491797C6.33708 0.0414968 6.95118 0.251053 7.477 0.610986C6.36425 1.18501 5.48066 2.12153 4.97227 3.26576C4.46389 4.41 4.36114 5.69344 4.681 6.90399C3.91688 6.7199 3.23694 6.28404 2.75062 5.66657C2.26431 5.04911 1.99991 4.28597 2 3.49999ZM5.1 7.99999H4C2.93913 7.99999 1.92172 8.42141 1.17157 9.17156C0.421427 9.9217 0 10.9391 0 12C0 12.5304 0.210714 13.0391 0.585786 13.4142C0.960859 13.7893 1.46957 14 2 14H2.5C2.49952 12.9326 2.78388 11.8845 3.32373 10.9637C3.86358 10.0429 4.63935 9.28286 5.571 8.76199C5.39356 8.5212 5.23604 8.26636 5.1 7.99999Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/assets/integrations/aws-marketplace.svg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/integrations/crowdstrike.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src/assets/integrations/firehose.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/integrations/fleetdm.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
src/assets/integrations/generic-http.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
src/assets/integrations/generic-scim.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/integrations/huntress.png
Normal file
|
After Width: | Height: | Size: 26 KiB |