mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67a23fc1f | ||
|
|
5a5e04ad35 | ||
|
|
598ecb196d | ||
|
|
7c8352822d | ||
|
|
e4f5046ff2 | ||
|
|
d5f61efc1a | ||
|
|
3503141c56 | ||
|
|
a27e4654cf | ||
|
|
b9569f48d5 | ||
|
|
9694bb753d | ||
|
|
377d239a01 | ||
|
|
a266fff89d | ||
|
|
8ed69f21c5 | ||
|
|
58d1035b16 | ||
|
|
991234785c | ||
|
|
f2ad889fdc | ||
|
|
1f219c4ffc | ||
|
|
87b3a08adc | ||
|
|
cfd0037cef | ||
|
|
ac7d578bd7 | ||
|
|
8d41de995c | ||
|
|
30dd75d95d | ||
|
|
bfa064a19d | ||
|
|
db73d18370 | ||
|
|
ba32178425 | ||
|
|
4ea4fd4a4c | ||
|
|
deaae9159c | ||
|
|
d7fd24244d | ||
|
|
9230b3e4e3 | ||
|
|
19ad8d6806 | ||
|
|
38fc2970e6 | ||
|
|
4ec4d29e84 | ||
|
|
944d1b5bf6 | ||
|
|
c325c170ab |
No files matched your search
@@ -39,7 +39,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "*.ts"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "*.js"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "ironrdp_web_bg.wasm"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Sync deploy templates to S3
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "templates/reverse-proxy/**"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: ${{ secrets.DEPLOY_TEMPLATES_ROLE_ARN }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- name: Validate CloudFormation template
|
||||
run: |
|
||||
aws cloudformation validate-template \
|
||||
--template-body file://templates/reverse-proxy/netbird-proxy-cfn.yaml
|
||||
|
||||
- name: Upload CloudFormation template
|
||||
run: |
|
||||
aws s3 cp templates/reverse-proxy/netbird-proxy-cfn.yaml \
|
||||
s3://netbird-deploy-templates/templates/netbird-proxy-cfn.yaml
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Test cloud deploy config
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "docker/init_react_envs.sh"
|
||||
- "templates/reverse-proxy/**"
|
||||
- "src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx"
|
||||
- ".github/workflows/test-cloud-deploy.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
csp-connect-src:
|
||||
name: CSP allows cloud provider APIs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install nginx
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nginx gettext-base
|
||||
|
||||
- name: Prepare nginx layout expected by the init script
|
||||
run: |
|
||||
sudo mkdir -p /etc/nginx/http.d /usr/share/nginx/html
|
||||
echo 'add_header Content-Security-Policy "placeholder" always;' | sudo tee /etc/nginx/http.d/default.conf
|
||||
sudo touch /usr/share/nginx/html/OidcTrustedDomains.js.tmpl
|
||||
sudo systemctl start nginx
|
||||
|
||||
- name: Run init script with production-like env
|
||||
env:
|
||||
AUTH_AUTHORITY: https://auth.example.com
|
||||
AUTH_CLIENT_ID: test-client
|
||||
AUTH_AUDIENCE: test-audience
|
||||
AUTH_SUPPORTED_SCOPES: openid profile email
|
||||
USE_AUTH0: "false"
|
||||
NETBIRD_MGMT_API_ENDPOINT: https://api.example.com
|
||||
run: sudo -E bash docker/init_react_envs.sh
|
||||
|
||||
- name: Assert provider APIs are in connect-src
|
||||
run: |
|
||||
CSP=$(grep -o 'Content-Security-Policy "[^"]*"' /etc/nginx/http.d/default.conf)
|
||||
echo "Generated CSP: $CSP"
|
||||
CONNECT_SRC=$(echo "$CSP" | sed 's/.*connect-src \([^;]*\);.*/\1/')
|
||||
echo "connect-src: $CONNECT_SRC"
|
||||
status=0
|
||||
for origin in https://api.hetzner.cloud https://api.digitalocean.com; do
|
||||
if echo "$CONNECT_SRC" | grep -qF "$origin"; then
|
||||
echo "OK: $origin"
|
||||
else
|
||||
echo "MISSING in connect-src: $origin"
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit $status
|
||||
|
||||
cfn-template-url:
|
||||
name: CloudFormation template URL is live
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract template URL from component and check it
|
||||
run: |
|
||||
URL=$(grep -o 'https://[^"]*netbird-proxy-cfn\.yaml' src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx)
|
||||
echo "CFN_TEMPLATE_URL: $URL"
|
||||
test -n "$URL"
|
||||
# Non-blocking: the template is published by sync-deploy-templates on
|
||||
# the default branch, so it may not exist yet for a PR that only edits
|
||||
# the URL. Warn instead of failing the check.
|
||||
if curl -sfI "$URL" >/dev/null; then
|
||||
echo "OK: template URL is live"
|
||||
else
|
||||
echo "::warning::CloudFormation template URL is not reachable yet: $URL"
|
||||
fi
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
[
|
||||
{
|
||||
"tag": "Preview",
|
||||
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
|
||||
"link": "https://github.com/netbirdio/netbird/discussions/6483",
|
||||
"tag": "New",
|
||||
"text": "The new NetBird desktop app is here - now available in the v0.75 release.",
|
||||
"link": "https://netbird.io/knowledge-hub/netbird-v0-75-new-desktop-app",
|
||||
"linkText": "Learn more",
|
||||
"variant": "important",
|
||||
"isExternal": true,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the netbird-dashboard Docker image for linux/amd64 and export it as a
|
||||
# portable tarball that can be loaded and run on a remote server.
|
||||
#
|
||||
# The dashboard image is nginx serving a Next.js *static export*: the Dockerfile
|
||||
# only COPYs the pre-built out/ directory, so we build out/ on the host first
|
||||
# (the static files are arch-independent) and then assemble the amd64 image.
|
||||
#
|
||||
# Usage:
|
||||
# ./build.sh # build + export netbird-dashboard.tar.gz
|
||||
# IMAGE_TAG=v1 ./build.sh # override the image tag
|
||||
# SKIP_BUILD=1 ./build.sh # reuse an existing out/ (skip npm build)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# Always run relative to this script's directory (the project root).
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-amd64}"
|
||||
IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
OUTPUT="${OUTPUT:-${IMAGE_NAME}.tar.gz}"
|
||||
|
||||
# Build the static export (out/) unless told to reuse an existing one. Next 16
|
||||
# no longer runs ESLint during `next build`; types are checked by the build via
|
||||
# tsc (verify separately with `npx tsc --noEmit`).
|
||||
if [[ "${SKIP_BUILD:-0}" != "1" ]]; then
|
||||
if [[ ! -d node_modules ]]; then
|
||||
echo "==> Installing dependencies (npm ci)"
|
||||
npm ci
|
||||
fi
|
||||
echo "==> Building static export (next build) into out/"
|
||||
npx next build
|
||||
fi
|
||||
|
||||
if [[ ! -d out ]]; then
|
||||
echo "ERROR: out/ not found — the static export did not build." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The .dockerignore lives under docker/, but Docker reads it from the build
|
||||
# context root. Stage it for the duration of the build so node_modules/.next
|
||||
# don't get shipped into the build context.
|
||||
cleanup() { rm -f .dockerignore; }
|
||||
trap cleanup EXIT
|
||||
cp docker/.dockerignore .dockerignore
|
||||
|
||||
echo "==> Building ${IMAGE} for linux/amd64"
|
||||
docker build \
|
||||
--platform linux/amd64 \
|
||||
-f docker/Dockerfile \
|
||||
-t "${IMAGE}" \
|
||||
.
|
||||
|
||||
echo "==> Exporting ${IMAGE} to ${OUTPUT}"
|
||||
docker save "${IMAGE}" | gzip > "${OUTPUT}"
|
||||
|
||||
echo "==> Done: ${OUTPUT} ($(du -h "${OUTPUT}" | cut -f1))"
|
||||
echo
|
||||
echo "Copy and run on the server:"
|
||||
echo " scp ${OUTPUT} user@your-server:/tmp/"
|
||||
echo " docker load < /tmp/${OUTPUT}"
|
||||
echo
|
||||
echo "Run (nginx serves on container port 80). Set the runtime env vars the"
|
||||
echo "dashboard templates at startup — for the Agent Network onboarding test,"
|
||||
echo "flip NETBIRD_AGENT_NETWORK_ONLY=true:"
|
||||
echo " docker run -d --name ${IMAGE_NAME} --restart unless-stopped \\"
|
||||
echo " -p 8080:80 \\"
|
||||
echo " -e NETBIRD_MGMT_API_ENDPOINT=https://your-mgmt:443 \\"
|
||||
echo " -e AUTH_AUTHORITY=https://your-idp/realms/netbird \\"
|
||||
echo " -e AUTH_CLIENT_ID=netbird-dashboard \\"
|
||||
echo " -e AUTH_AUDIENCE=netbird-dashboard \\"
|
||||
echo " -e AUTH_SUPPORTED_SCOPES='openid profile email' \\"
|
||||
echo " -e USE_AUTH0=false \\"
|
||||
echo " -e NETBIRD_AGENT_NETWORK_ONLY=true \\"
|
||||
echo " ${IMAGE}"
|
||||
@@ -18,6 +18,8 @@
|
||||
"wasmPath": "$NETBIRD_WASM_PATH",
|
||||
"licensed": "$NETBIRD_LICENSED",
|
||||
"cloud": "$NETBIRD_CLOUD",
|
||||
"agentNetworkOnly": "$NETBIRD_AGENT_NETWORK_ONLY",
|
||||
"agentNetworkEnabled": "$NETBIRD_AGENT_NETWORK_ENABLED",
|
||||
"hubspotPortalId": "$NETBIRD_HUBSPOT_PORTAL_ID",
|
||||
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
|
||||
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# The image only needs the pre-built static export (out/) and the docker/
|
||||
# config files. Keep the build context small by dropping everything heavy and
|
||||
# irrelevant — without this, `docker build .` ships node_modules and .next.
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.github
|
||||
e2e
|
||||
src
|
||||
public
|
||||
*.tar.gz
|
||||
*.tar
|
||||
@@ -66,6 +66,8 @@ 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_AGENT_NETWORK_ONLY=${NETBIRD_AGENT_NETWORK_ONLY:-false}
|
||||
export NETBIRD_AGENT_NETWORK_ENABLED=${NETBIRD_AGENT_NETWORK_ENABLED:-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}
|
||||
@@ -78,7 +80,7 @@ echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
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_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com https://api.hetzner.cloud https://api.digitalocean.com"
|
||||
THIRD_PARTY_CSP_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com"
|
||||
|
||||
CSP_DOMAINS=""
|
||||
@@ -91,6 +93,8 @@ fi
|
||||
# Add AUTH_AUTHORITY to CSP
|
||||
if [[ -n "${AUTH_AUTHORITY}" ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY"
|
||||
AUTH_AUTHORITY_ORIGIN=$(echo "$AUTH_AUTHORITY" | sed -E 's|^(https?://[^/]+).*|\1|')
|
||||
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY_ORIGIN"
|
||||
fi
|
||||
|
||||
# Add AUTH_AUDIENCE to CSP
|
||||
@@ -137,8 +141,18 @@ CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' |
|
||||
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/ $//')
|
||||
|
||||
# upgrade-insecure-requests tells the browser to rewrite every http subresource,
|
||||
# fetch and WebSocket to https/wss. That is correct for https deployments but breaks
|
||||
# plain-http ones (e.g. local or self-hosted over http), where it would rewrite the
|
||||
# working http/ws endpoints to unreachable https/wss. Emit it only when the backend
|
||||
# is served over https, matching the ws:// vs wss:// scheme logic above.
|
||||
CSP_UPGRADE_INSECURE=" upgrade-insecure-requests;"
|
||||
if [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* || "$AUTH_AUTHORITY" == http://* ]]; then
|
||||
CSP_UPGRADE_INSECURE=""
|
||||
fi
|
||||
|
||||
# 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_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';$CSP_UPGRADE_INSECURE"
|
||||
CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;"
|
||||
|
||||
echo "CSP header: $CSP_HEADER"
|
||||
@@ -149,7 +163,7 @@ sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /
|
||||
}
|
||||
|
||||
# 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_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"
|
||||
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_AGENT_NETWORK_ONLY \$\$NETBIRD_AGENT_NETWORK_ENABLED \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
@@ -157,4 +171,25 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
|
||||
cp "$f" "$f".copy
|
||||
envsubst "$ENV_STR" < "$f".copy > "$f"
|
||||
rm "$f".copy
|
||||
done
|
||||
done
|
||||
|
||||
# Reload nginx so the patched CSP header takes effect.
|
||||
# supervisord starts nginx (priority 100) before this script (priority 201) and
|
||||
# never reloads it, so without this nginx keeps serving the static default.conf
|
||||
# CSP that has no connect-src, breaking OIDC discovery over plain HTTP.
|
||||
reloaded=false
|
||||
for i in $(seq 1 10); do
|
||||
if nginx -s reload 2>/dev/null; then
|
||||
echo "Reloaded nginx to apply updated CSP configuration"
|
||||
reloaded=true
|
||||
break
|
||||
fi
|
||||
echo "Waiting for nginx to be ready before reload (attempt $i)..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "$reloaded" != true ]]; then
|
||||
echo "Failed to reload nginx after patching CSP header" >&2
|
||||
nginx -t || true
|
||||
exit 1
|
||||
fi
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -433,3 +433,53 @@ export async function deleteUserByEmail(page: Page, email: string) {
|
||||
await deleteUserById(page, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Network
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AgentNetworkCatalogProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type AgentNetworkProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider_id: string;
|
||||
};
|
||||
|
||||
/** List the Agent Network provider catalog (server-defined provider types). */
|
||||
export async function listAgentNetworkCatalog(
|
||||
page: Page,
|
||||
): Promise<AgentNetworkCatalogProvider[]> {
|
||||
return apiGet<AgentNetworkCatalogProvider[]>(
|
||||
page,
|
||||
"/agent-network/catalog/providers",
|
||||
);
|
||||
}
|
||||
|
||||
/** List connected Agent Network providers. */
|
||||
export async function listAgentNetworkProviders(
|
||||
page: Page,
|
||||
): Promise<AgentNetworkProvider[]> {
|
||||
return apiGet<AgentNetworkProvider[]>(page, "/agent-network/providers");
|
||||
}
|
||||
|
||||
/** Delete an Agent Network provider by ID. */
|
||||
export async function deleteAgentNetworkProviderById(page: Page, id: string) {
|
||||
await apiDelete(page, `/agent-network/providers/${id}`);
|
||||
}
|
||||
|
||||
/** Delete all Agent Network providers whose name starts with the prefix. */
|
||||
export async function deleteAgentNetworkProvidersByPrefix(
|
||||
page: Page,
|
||||
prefix: string,
|
||||
) {
|
||||
const providers = await listAgentNetworkProviders(page);
|
||||
for (const p of providers) {
|
||||
if (p.name.startsWith(prefix)) {
|
||||
await deleteAgentNetworkProviderById(page, p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -20,7 +20,11 @@ const credentials: Record<TestUser, { username: string; password: string }> = {
|
||||
/**
|
||||
* Navigate to the app, authenticate via Zitadel, and wait for the app to load.
|
||||
*/
|
||||
export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
export async function loginToApp(
|
||||
page: Page,
|
||||
user: TestUser = "owner",
|
||||
opts: { expectOnboarding?: boolean } = {},
|
||||
) {
|
||||
const { username, password } = credentials[user];
|
||||
|
||||
await page.goto("/");
|
||||
@@ -39,7 +43,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
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),
|
||||
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),
|
||||
@@ -51,6 +57,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
}
|
||||
|
||||
if (which === "modal") {
|
||||
// Onboarding tests render a full-screen overlay over the setup modal;
|
||||
// clicking modal-close would be pointer-intercepted, so leave it be.
|
||||
if (opts.expectOnboarding) return;
|
||||
await setupModal.getByTestId("modal-close").click();
|
||||
await expect(setupModal).not.toBeVisible();
|
||||
return;
|
||||
@@ -89,8 +98,12 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
onboarding.waitFor({ timeout: 15_000 }),
|
||||
]);
|
||||
|
||||
// Dismiss setup modal if present
|
||||
if (await setupModal.isVisible().catch(() => false)) {
|
||||
// Dismiss setup modal if present, unless an onboarding overlay is expected
|
||||
// on top of it (clicking through it would be pointer-intercepted).
|
||||
if (
|
||||
!opts.expectOnboarding &&
|
||||
(await setupModal.isVisible().catch(() => false))
|
||||
) {
|
||||
await setupModal.getByTestId("modal-close").click();
|
||||
await expect(setupModal).not.toBeVisible();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Agent Network focused-view spec.
|
||||
*
|
||||
* Exercises the account-driven focused view added for netbird.ai signups:
|
||||
* the `agent_network_only` account setting hides the rest of the dashboard,
|
||||
* an explicit `false` opts back out, and a pending netbird.ai signup applies
|
||||
* the focused view optimistically and persists the setting via PUT /accounts.
|
||||
*
|
||||
* The test management backend does not know the setting, so GET /accounts is
|
||||
* rewritten per-test to inject it (same interception approach as
|
||||
* edition-gating.spec.ts). "only" mode is asserted through the Networks nav
|
||||
* item, which toggles purely on the focused-view flag and does not depend on
|
||||
* premium permission modules.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp, navigateTo } from "../helpers/auth";
|
||||
|
||||
const SIGNUP_SOURCE_KEY = "netbird-signup-source";
|
||||
const AGENT_NETWORK_SOURCE = "netbird.ai";
|
||||
// Drives the NETBIRD_AGENT_NETWORK_ONLY / _ENABLED deployment flags against the
|
||||
// test build (see testAgentNetworkOverride in utils/netbird.ts).
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
type AgentNetworkConfig = "only" | "enabled" | "off";
|
||||
|
||||
type AccountMock = {
|
||||
// undefined leaves the setting absent (as an un-onboarded account would be).
|
||||
agentNetworkOnly?: boolean;
|
||||
signupFormPending?: boolean;
|
||||
// dashboardFeaturesAgentNetwork sets settings.dashboard_features.agent_network,
|
||||
// which makes the Agent Network menu available without focusing the dashboard.
|
||||
dashboardFeaturesAgentNetwork?: boolean;
|
||||
};
|
||||
|
||||
// mockAccounts rewrites GET /accounts to inject the focused-view setting and
|
||||
// onboarding state onto the real account. A PUT to /accounts/{id} is captured
|
||||
// and flips the injected setting to true, mirroring the server persisting the
|
||||
// value so the optimistic view does not revert once the write settles.
|
||||
function mockAccounts(
|
||||
page: Page,
|
||||
initial: AccountMock,
|
||||
captured: { putBody?: any },
|
||||
) {
|
||||
let applied = initial.agentNetworkOnly;
|
||||
|
||||
page.route("**/api/accounts", 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[0]) {
|
||||
body[0].settings = { ...(body[0].settings ?? {}) };
|
||||
if (applied === undefined) {
|
||||
delete body[0].settings.agent_network_only;
|
||||
} else {
|
||||
body[0].settings.agent_network_only = applied;
|
||||
}
|
||||
if (initial.dashboardFeaturesAgentNetwork !== undefined) {
|
||||
body[0].settings.dashboard_features = {
|
||||
agent_network: initial.dashboardFeaturesAgentNetwork,
|
||||
};
|
||||
}
|
||||
body[0].onboarding = {
|
||||
...(body[0].onboarding ?? {}),
|
||||
signup_form_pending: !!initial.signupFormPending,
|
||||
};
|
||||
}
|
||||
return route.fulfill({ response, json: body });
|
||||
});
|
||||
|
||||
page.route("**/api/accounts/*", async (route) => {
|
||||
if (route.request().method() !== "PUT") return route.continue();
|
||||
try {
|
||||
captured.putBody = route.request().postDataJSON();
|
||||
} catch (e) {
|
||||
captured.putBody = null;
|
||||
}
|
||||
applied = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ settings: { agent_network_only: true } }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openWithAccount(
|
||||
browser: Browser,
|
||||
account: AccountMock,
|
||||
opts: {
|
||||
signupSource?: boolean;
|
||||
agentNetworkConfig?: AgentNetworkConfig;
|
||||
} = {},
|
||||
): Promise<{
|
||||
page: Page;
|
||||
captured: { putBody?: any };
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
if (opts.signupSource) {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE],
|
||||
);
|
||||
}
|
||||
if (opts.agentNetworkConfig) {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, opts.agentNetworkConfig],
|
||||
);
|
||||
}
|
||||
const page = await context.newPage();
|
||||
const captured: { putBody?: any } = {};
|
||||
mockAccounts(page, account, captured);
|
||||
await loginToApp(page, "owner");
|
||||
return { page, captured, close: () => context.close() };
|
||||
}
|
||||
|
||||
function navItem(page: Page, text: string) {
|
||||
return page
|
||||
.getByTestId("left-navigation-item")
|
||||
.getByText(text, { exact: true });
|
||||
}
|
||||
|
||||
// navItemContaining matches a sidebar entry by a substring of its label. Some
|
||||
// entries (Agent Network, Reverse Proxy) render a "Beta" badge inside the label
|
||||
// when the dashboard is not focused, so exact text matching would miss them.
|
||||
function navItemContaining(page: Page, text: string) {
|
||||
return page.getByTestId("left-navigation-item").filter({ hasText: text });
|
||||
}
|
||||
|
||||
// Regular dashboard sections that the focused view hides (gated on
|
||||
// !agentNetworkOnly in Navigation.tsx).
|
||||
const FOCUS_HIDDEN_NAV = ["Networks", "Reverse Proxy", "DNS", "Activity"];
|
||||
// Agent Network view routes that make up the focused surface.
|
||||
const AGENT_VIEW_PATHS = [
|
||||
"/agent-network/providers",
|
||||
"/agent-network/policies",
|
||||
"/agent-network/usage",
|
||||
"/agent-network/configuration",
|
||||
];
|
||||
|
||||
test.describe.serial("Agent Network focused view @agent-network", () => {
|
||||
test("focused menu shows only Agent Network views and hides the regular sections", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(browser, {
|
||||
agentNetworkOnly: true,
|
||||
});
|
||||
try {
|
||||
// Peers always renders for an owner and is not part of the focused
|
||||
// split, so it confirms the sidebar rendered before asserting absences.
|
||||
await expect(navItem(page, "Peers")).toBeVisible();
|
||||
|
||||
// The Agent Network section is present and the regular sections are gone.
|
||||
await expect(navItem(page, "Agent Network")).toBeVisible();
|
||||
for (const label of FOCUS_HIDDEN_NAV) {
|
||||
await expect(navItem(page, label)).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("focused view keeps every Agent Network route reachable", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(browser, {
|
||||
agentNetworkOnly: true,
|
||||
});
|
||||
try {
|
||||
// Each Agent Network view renders instead of the route guard redirecting
|
||||
// away; the Agent Network section stays in the sidebar throughout.
|
||||
for (const path of AGENT_VIEW_PATHS) {
|
||||
await navigateTo(page, path);
|
||||
await expect(page).toHaveURL(new RegExp(path.replace(/\//g, "\\/")));
|
||||
await expect(navItem(page, "Agent Network")).toBeVisible();
|
||||
}
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit opt-out restores the regular menu and removes Agent Network", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(browser, {
|
||||
agentNetworkOnly: false,
|
||||
});
|
||||
try {
|
||||
// The sidebar rendered (Peers is always present for an owner)...
|
||||
await expect(navItem(page, "Peers")).toBeVisible();
|
||||
// ...and the Agent Network section is gone: the explicit opt-out wins
|
||||
// and, without a deployment config flag, the section is not enabled.
|
||||
await expect(navItem(page, "Agent Network")).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("dashboard_features.agent_network makes the menu available without focusing", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(browser, {
|
||||
dashboardFeaturesAgentNetwork: true,
|
||||
});
|
||||
try {
|
||||
// The Agent Network menu is available (the label carries a "Beta" badge
|
||||
// outside focused mode, so match on the label substring)...
|
||||
await expect(navItemContaining(page, "Agent Network")).toBeVisible();
|
||||
// ...but the dashboard is not focused: a regular section that focused
|
||||
// mode hides (Reverse Proxy) is still present.
|
||||
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("applies the focused view optimistically for a pending netbird.ai signup and persists it", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, captured, close } = await openWithAccount(
|
||||
browser,
|
||||
{ agentNetworkOnly: undefined, signupFormPending: true },
|
||||
{ signupSource: true },
|
||||
);
|
||||
try {
|
||||
// Focused view is applied immediately, before the setting is persisted.
|
||||
await expect(navItem(page, "Agent Network")).toBeVisible();
|
||||
await expect(navItem(page, "Networks")).toHaveCount(0);
|
||||
|
||||
// The signup source is persisted: focused view plus the Agent Network
|
||||
// menu flag, so turning focus off later keeps access to Agent Network.
|
||||
await expect
|
||||
.poll(() => captured.putBody?.settings?.agent_network_only)
|
||||
.toBe(true);
|
||||
expect(
|
||||
captured.putBody?.settings?.dashboard_features?.agent_network,
|
||||
).toBe(true);
|
||||
|
||||
// The focused view is retained after the write settles.
|
||||
await expect(navItem(page, "Networks")).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("existing netbird.ai account gets dashboard_features, not the focused view", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// An existing account (signup form already submitted) with the netbird.ai
|
||||
// source should have the Agent Network menu made available via
|
||||
// dashboard_features, not the focused agent_network_only view.
|
||||
const { page, captured, close } = await openWithAccount(
|
||||
browser,
|
||||
{ signupFormPending: false },
|
||||
{ signupSource: true },
|
||||
);
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() => captured.putBody?.settings?.dashboard_features?.agent_network,
|
||||
)
|
||||
.toBe(true);
|
||||
expect(captured.putBody?.settings?.agent_network_only).not.toBe(true);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
// Matrix of the two deployment flags (NETBIRD_AGENT_NETWORK_ONLY / _ENABLED,
|
||||
// "off" = neither) crossed with the netbird.ai signup source, with no explicit
|
||||
// account settings. The API defaults agent_network_only to false, so the
|
||||
// deployment flags must still take effect. A present source implies a new
|
||||
// signup (signup_form_pending), which focuses the dashboard optimistically
|
||||
// regardless of the flags.
|
||||
type Expectation = "hidden" | "available" | "focused";
|
||||
|
||||
const ENV_SIGNUP_MATRIX: {
|
||||
env: AgentNetworkConfig;
|
||||
signupSource: boolean;
|
||||
expected: Expectation;
|
||||
}[] = [
|
||||
{ env: "off", signupSource: false, expected: "hidden" },
|
||||
{ env: "off", signupSource: true, expected: "focused" },
|
||||
{ env: "enabled", signupSource: false, expected: "available" },
|
||||
{ env: "enabled", signupSource: true, expected: "focused" },
|
||||
{ env: "only", signupSource: false, expected: "focused" },
|
||||
{ env: "only", signupSource: true, expected: "focused" },
|
||||
];
|
||||
|
||||
async function expectAgentNetworkState(page: Page, expected: Expectation) {
|
||||
// Peers always renders for an owner, confirming the sidebar loaded.
|
||||
await expect(navItem(page, "Peers")).toBeVisible();
|
||||
|
||||
if (expected === "hidden") {
|
||||
await expect(navItemContaining(page, "Agent Network")).toHaveCount(0);
|
||||
// The regular dashboard stays intact.
|
||||
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(navItemContaining(page, "Agent Network")).toBeVisible();
|
||||
|
||||
if (expected === "focused") {
|
||||
for (const label of FOCUS_HIDDEN_NAV) {
|
||||
await expect(navItem(page, label)).toHaveCount(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// available: the menu shows alongside the regular dashboard.
|
||||
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
|
||||
}
|
||||
|
||||
for (const { env, signupSource, expected } of ENV_SIGNUP_MATRIX) {
|
||||
const sourceLabel = signupSource ? "netbird.ai" : "none";
|
||||
test(`env=${env} signup_source=${sourceLabel} -> ${expected}`, async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(
|
||||
browser,
|
||||
// A source present means a fresh netbird.ai signup (form still pending);
|
||||
// otherwise a plain account with no agent-network settings.
|
||||
{ signupFormPending: signupSource },
|
||||
{ agentNetworkConfig: env, signupSource },
|
||||
);
|
||||
try {
|
||||
await expectAgentNetworkState(page, expected);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("exposes the focused-view toggle in client settings", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(browser, {
|
||||
agentNetworkOnly: true,
|
||||
});
|
||||
try {
|
||||
await navigateTo(page, "/settings?tab=clients");
|
||||
await expect(page.getByTestId("agent-network-only")).toBeVisible();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Agent Network Kimi (Moonshot AI) provider spec.
|
||||
*
|
||||
* Walks the Kimi provider lifecycle end to end against the real backend:
|
||||
* pick kimi_api from the catalog (prefilled host, catalog models with
|
||||
* pricing), connect it, then verify the Kimi-gated config surfaces in the
|
||||
* "Configure Your Agent" modal (Kimi CLI tab, Kimi backend option in the
|
||||
* Claude Code tab) that only render when a Kimi provider is connected.
|
||||
*
|
||||
* The kimi_api catalog entry ships with newer management builds. When the
|
||||
* backend under test predates it, the whole suite skips instead of failing —
|
||||
* same trade-off as the provider matrix in netbird's agent-network e2e
|
||||
* harness, which skips per-provider on missing credentials.
|
||||
*
|
||||
* The Agent Network menu is deployment-gated; the test build honors the
|
||||
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
|
||||
* set via addInitScript on a dedicated context below.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import {
|
||||
deleteAgentNetworkProvidersByPrefix,
|
||||
listAgentNetworkCatalog,
|
||||
} from "../helpers/api";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
const KIMI_CATALOG_ID = "kimi_api";
|
||||
const KIMI_CATALOG_NAME = "Kimi (Moonshot AI) API";
|
||||
const PROVIDER_PREFIX = "e2e-kimi-";
|
||||
|
||||
async function newAgentNetworkPage(browser: Browser): Promise<{
|
||||
page: Page;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
await loginToApp(page, "owner");
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
test.describe.serial("Agent Network Kimi provider @agent-network", () => {
|
||||
test("connect a Kimi provider and see Kimi agent configs", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await newAgentNetworkPage(browser);
|
||||
try {
|
||||
// Backend support probe: skip the suite on management builds whose
|
||||
// catalog predates kimi_api.
|
||||
const catalog = await listAgentNetworkCatalog(page);
|
||||
test.skip(
|
||||
!catalog.some((c) => c.id === KIMI_CATALOG_ID),
|
||||
`management catalog has no ${KIMI_CATALOG_ID} entry`,
|
||||
);
|
||||
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// ---- connect the provider ----
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
// The provider select defaults to OpenAI API; search the catalog for
|
||||
// the Kimi entry.
|
||||
await page
|
||||
.getByRole("button", { name: /OpenAI API/ })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
await page.getByPlaceholder("Search providers...").fill("kimi");
|
||||
await page.getByText(KIMI_CATALOG_NAME).first().click({ force: true });
|
||||
|
||||
// Catalog default host lands in the upstream URL input.
|
||||
await expect(
|
||||
page.locator('input[value="https://api.moonshot.ai"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByPlaceholder("sk-...")
|
||||
.first()
|
||||
.fill("sk-e2e-kimi-test-key");
|
||||
|
||||
const providerName = generateRandomName(PROVIDER_PREFIX);
|
||||
const nameInput = page.locator(`input[value="${KIMI_CATALOG_NAME}"]`);
|
||||
await nameInput.fill(providerName);
|
||||
|
||||
// ---- models tab: catalog models carry pricing ----
|
||||
await page.getByRole("tab", { name: "Models" }).click({ force: true });
|
||||
await page.getByRole("button", { name: "Add More" }).click({ force: true });
|
||||
await page.getByText("Kimi K3", { exact: false }).first().click({ force: true });
|
||||
// Catalog price for kimi-k3 input tokens pre-fills the price cell.
|
||||
await expect(page.locator('input[value="0.003"]')).toBeVisible();
|
||||
|
||||
const createResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/agent-network/providers") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.last()
|
||||
.click({ force: true });
|
||||
expect([200, 201]).toContain((await createResponse).status());
|
||||
|
||||
// Row lands in the providers table.
|
||||
await expect(page.getByText(providerName).first()).toBeVisible();
|
||||
|
||||
// ---- Kimi-gated agent config surfaces ----
|
||||
await page
|
||||
.getByRole("button", { name: "Agent Config" })
|
||||
.click({ force: true });
|
||||
|
||||
// Kimi CLI tab only renders when a kimi_api provider is connected.
|
||||
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible();
|
||||
|
||||
// Claude Code tab's backend dropdown offers (and, with Kimi as the only
|
||||
// Anthropic-shaped provider, pre-selects) Kimi — its settings.json
|
||||
// snippet pins every model slot to kimi-k3 and disables tool search,
|
||||
// per Moonshot's Claude Code guide.
|
||||
await expect(page.getByText('"ANTHROPIC_MODEL": "kimi-k3"')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('"CLAUDE_CODE_SUBAGENT_MODEL": "kimi-k3"'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('"ENABLE_TOOL_SEARCH": "false"'),
|
||||
).toBeVisible();
|
||||
// The base URL carries the /anthropic shape prefix that rides through
|
||||
// the endpoint to Moonshot's Anthropic surface.
|
||||
await expect(
|
||||
page.getByText(/"ANTHROPIC_BASE_URL": "https:\/\/[^"]+\/anthropic"/),
|
||||
).toBeVisible();
|
||||
|
||||
// Kimi CLI tab carries the ~/.kimi/config.toml provider block.
|
||||
await page.getByRole("tab", { name: "Kimi CLI" }).click({ force: true });
|
||||
await expect(page.getByText('default_model = "kimi-k3"')).toBeVisible();
|
||||
|
||||
// Codex has no Kimi variant — Kimi's upstream doesn't support Codex, so
|
||||
// the tab keeps the plain Responses-API config with no backend dropdown.
|
||||
await page.getByRole("tab", { name: "Codex" }).click({ force: true });
|
||||
await expect(page.getByText('wire_api = "responses"')).toBeVisible();
|
||||
await expect(page.getByText('wire_api = "chat"')).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// ---- cleanup ----
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Validates that the dashboard surfaces the management API's request id in the
|
||||
* error toast. Newer management servers set an `X-Request-Id` response header
|
||||
* (see telemetry.RequestIDHeader). When present, the dashboard shows it in the
|
||||
* error notification so it can be quoted in support tickets. Older servers omit
|
||||
* the header, and the toast must render without a request id.
|
||||
*
|
||||
* Each scenario runs in its own browser context so the mocked `/networks`
|
||||
* route and its resulting toast never bleed into another test through the
|
||||
* worker-scoped page.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp, navigateTo } from "../helpers/auth";
|
||||
import * as path from "path";
|
||||
|
||||
const SCREENSHOTS_DIR = path.resolve(__dirname, "..", "screenshots");
|
||||
const REQUEST_ID = "9m4e2mr0ui3e8a215n4g";
|
||||
const NETWORKS_ENDPOINT = /\/api\/networks(\?|$)/;
|
||||
const REQUEST_ID_TESTID = "notification-request-id";
|
||||
const TITLE_TESTID = "notification-title";
|
||||
|
||||
// failNetworks mocks the networks list GET with a 500, optionally attaching the
|
||||
// X-Request-Id header to emulate a newer management server. The dashboard talks
|
||||
// to the management API cross-origin, so the mock must mirror a CORS-enabled
|
||||
// server: the browser only lets JS read a non-safelisted response header when it
|
||||
// is listed in Access-Control-Expose-Headers. This is the same requirement the
|
||||
// real management server must satisfy for the feature to work in production.
|
||||
async function failNetworks(page: Page, opts: { withRequestId: boolean }) {
|
||||
await page.route(NETWORKS_ENDPOINT, async (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
const origin = route.request().headers()["origin"] || "*";
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"access-control-allow-origin": origin,
|
||||
"access-control-expose-headers": "X-Request-Id",
|
||||
};
|
||||
if (opts.withRequestId) headers["X-Request-Id"] = REQUEST_ID;
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
headers,
|
||||
body: JSON.stringify({ code: 500, message: "internal server error" }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openOwner(
|
||||
browser: Browser,
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
test.describe.serial("API error request id @error-handling", () => {
|
||||
test("shows the X-Request-Id from a failed request in the error toast", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOwner(browser);
|
||||
try {
|
||||
await failNetworks(page, { withRequestId: true });
|
||||
await loginToApp(page, "owner");
|
||||
await navigateTo(page, "/networks");
|
||||
|
||||
const requestIdLine = page.getByTestId(REQUEST_ID_TESTID);
|
||||
await expect(requestIdLine).toBeVisible();
|
||||
await expect(requestIdLine).toContainText(REQUEST_ID);
|
||||
await expect(page.getByTestId(TITLE_TESTID).first()).toContainText(
|
||||
"Request failed with status code 500",
|
||||
);
|
||||
|
||||
const toast = page.locator("[data-toast-notification]").first();
|
||||
await toast.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, "error-toast-with-request-id.png"),
|
||||
});
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, "networks-page-error-request-id.png"),
|
||||
});
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("omits the request id when the server does not send the header", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOwner(browser);
|
||||
try {
|
||||
await failNetworks(page, { withRequestId: false });
|
||||
await loginToApp(page, "owner");
|
||||
await navigateTo(page, "/networks");
|
||||
|
||||
// The generic error toast still appears for older servers...
|
||||
await expect(page.getByTestId(TITLE_TESTID).first()).toContainText(
|
||||
"Request failed with status code 500",
|
||||
);
|
||||
// ...but no request id line is rendered.
|
||||
await expect(page.getByTestId(REQUEST_ID_TESTID)).toHaveCount(0);
|
||||
|
||||
const toast = page.locator("[data-toast-notification]").first();
|
||||
await toast.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, "error-toast-without-request-id.png"),
|
||||
});
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Onboarding form selection spec.
|
||||
*
|
||||
* Validates which onboarding form is shown first and which one persists,
|
||||
* across signup-source and account states — the recurring "regular form
|
||||
* flashes before the Agent Network form" bug. Onboarding is disabled in the
|
||||
* test build by default; each test opts in via the `netbird-test-onboarding`
|
||||
* localStorage flag (see testOnboardingEnabled in src/utils/netbird.ts), and
|
||||
* GET /accounts is rewritten to drive the account's onboarding + settings
|
||||
* state (same interception approach as edition-gating.spec.ts).
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const SIGNUP_SOURCE_KEY = "netbird-signup-source";
|
||||
const AGENT_NETWORK_SOURCE = "netbird.ai";
|
||||
|
||||
const AGENT_FORM = "agent-network-onboarding";
|
||||
const REGULAR_FORM = "regular-onboarding";
|
||||
|
||||
type AccountState = {
|
||||
signupFormPending?: boolean;
|
||||
onboardingFlowPending?: boolean;
|
||||
agentNetworkOnly?: boolean;
|
||||
};
|
||||
|
||||
// mockAccounts rewrites GET /accounts to drive the onboarding + focused-view
|
||||
// state, optionally delaying the response to emulate a slow backend. A PUT to
|
||||
// /accounts/{id} flips agent_network_only to true afterwards, mirroring the
|
||||
// server persisting the netbird.ai signup mark.
|
||||
function mockAccounts(page: Page, state: AccountState, delayMs: number) {
|
||||
let agentNetworkOnly = state.agentNetworkOnly;
|
||||
|
||||
page.route("**/api/accounts", async (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
const response = await route.fetch();
|
||||
let body: any;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (e) {
|
||||
return route.fulfill({ response });
|
||||
}
|
||||
if (Array.isArray(body) && body[0]) {
|
||||
body[0].onboarding = {
|
||||
...(body[0].onboarding ?? {}),
|
||||
signup_form_pending: !!state.signupFormPending,
|
||||
onboarding_flow_pending: !!state.onboardingFlowPending,
|
||||
};
|
||||
body[0].settings = { ...(body[0].settings ?? {}) };
|
||||
if (agentNetworkOnly === undefined) {
|
||||
delete body[0].settings.agent_network_only;
|
||||
} else {
|
||||
body[0].settings.agent_network_only = agentNetworkOnly;
|
||||
}
|
||||
}
|
||||
return route.fulfill({ response, json: body });
|
||||
});
|
||||
|
||||
page.route("**/api/accounts/*", async (route) => {
|
||||
if (route.request().method() !== "PUT") return route.continue();
|
||||
agentNetworkOnly = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ settings: { agent_network_only: true } }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openOnboarding(
|
||||
browser: Browser,
|
||||
opts: { source?: boolean; account: AccountState; delayMs?: number },
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
await context.addInitScript(
|
||||
([sourceKey, sourceValue, withSource]) => {
|
||||
try {
|
||||
window.localStorage.setItem("netbird-test-onboarding", "true");
|
||||
if (withSource) {
|
||||
window.localStorage.setItem(
|
||||
sourceKey as string,
|
||||
sourceValue as string,
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE, !!opts.source] as const,
|
||||
);
|
||||
const page = await context.newPage();
|
||||
mockAccounts(page, opts.account, opts.delayMs ?? 0);
|
||||
await loginToApp(page, "owner", { expectOnboarding: true });
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
test.describe.serial("Onboarding form selection @onboarding", () => {
|
||||
test("netbird.ai signup shows the Agent Network form and never the regular one", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOnboarding(browser, {
|
||||
source: true,
|
||||
account: { signupFormPending: true, onboardingFlowPending: true },
|
||||
});
|
||||
try {
|
||||
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a plain new cloud account shows the regular onboarding form", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOnboarding(browser, {
|
||||
source: false,
|
||||
account: { signupFormPending: true, onboardingFlowPending: true },
|
||||
});
|
||||
try {
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toBeVisible();
|
||||
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an account already marked agent_network_only shows the Agent Network form", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOnboarding(browser, {
|
||||
source: false,
|
||||
account: { onboardingFlowPending: true, agentNetworkOnly: true },
|
||||
});
|
||||
try {
|
||||
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a slow backend never flashes the regular form for a netbird.ai signup", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOnboarding(browser, {
|
||||
source: true,
|
||||
account: { signupFormPending: true, onboardingFlowPending: true },
|
||||
delayMs: 1500,
|
||||
});
|
||||
try {
|
||||
// While /accounts is still loading, no onboarding form should have been
|
||||
// committed to the wrong flow.
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
|
||||
// Once it resolves, the Agent Network form is the one that appears...
|
||||
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
|
||||
// ...and it persists after the setting is applied (no flip/close).
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,7 @@ async function revokeSetupKey(
|
||||
);
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await responsePromise;
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator("tr")
|
||||
@@ -158,4 +159,5 @@ async function deleteSetupKey(
|
||||
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();
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
}
|
||||
Generated
+1
-21
@@ -172,7 +172,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1456,7 +1455,6 @@
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
@@ -3097,7 +3095,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -3107,7 +3104,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -3166,7 +3162,6 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -3634,8 +3629,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.1",
|
||||
@@ -3674,7 +3668,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4100,7 +4093,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4236,7 +4228,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -4754,7 +4745,6 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -5276,7 +5266,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
|
||||
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -5406,7 +5395,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -6791,7 +6779,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -7625,7 +7612,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -7833,7 +7819,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7894,7 +7879,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -8767,7 +8751,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -8934,7 +8917,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -9087,7 +9069,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -9415,7 +9396,6 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ export default function AccessControlPage() {
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Access Control Policies</h1>
|
||||
<Paragraph>
|
||||
Create rules to manage access in your network and define what peers
|
||||
can connect.{" "}
|
||||
Policies connect users and agents to your network resources,
|
||||
controlling what each identity can reach.{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
|
||||
Loaded 20 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user