mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
10
Commits
embedded-vnc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4451274c7 | ||
|
|
b2e658a2be | ||
|
|
4b2c394181 | ||
|
|
74eae99820 | ||
|
|
da81cb7f50 | ||
|
|
f4e80d37c4 | ||
|
|
de045582e5 | ||
|
|
6a85748b91 | ||
|
|
a9a91905fa | ||
|
|
3459a4db28 |
No files matched your search
File renamed without changes.
@@ -98,6 +98,24 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Docker meta (rootless)
|
||||
id: meta_rootless
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_IMAGE }}
|
||||
${{ env.GHCR_IMAGE }}
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=-rootless,onlatest=true
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@v3
|
||||
@@ -123,7 +141,18 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push rootless Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.rootless
|
||||
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm
|
||||
tags: ${{ steps.meta_rootless.outputs.tags }}
|
||||
labels: ${{ steps.meta_rootless.outputs.labels }}
|
||||
|
||||
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
|
||||
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
|
||||
echo '${{ steps.meta_rootless.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
|
||||
@@ -11,6 +11,7 @@
|
||||
# ./build.sh # build + export netbird-dashboard.tar.gz
|
||||
# IMAGE_TAG=v1 ./build.sh # override the image tag
|
||||
# SKIP_BUILD=1 ./build.sh # reuse an existing out/ (skip npm build)
|
||||
# ROOTLESS=1 ./build.sh # build the amd64-rootless image variant
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
@@ -19,8 +20,26 @@ cd "$(dirname "$0")"
|
||||
|
||||
IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-amd64}"
|
||||
DOCKERFILE="docker/Dockerfile"
|
||||
CONTAINER_PORT=80
|
||||
OUTPUT_SUFFIX=""
|
||||
|
||||
case "${ROOTLESS:-0}" in
|
||||
0) ;;
|
||||
1)
|
||||
DOCKERFILE="docker/Dockerfile.rootless"
|
||||
CONTAINER_PORT=8080
|
||||
OUTPUT_SUFFIX="-rootless"
|
||||
[[ "${IMAGE_TAG}" == *-rootless ]] || IMAGE_TAG="${IMAGE_TAG}-rootless"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: ROOTLESS must be 0 or 1." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
OUTPUT="${OUTPUT:-${IMAGE_NAME}.tar.gz}"
|
||||
OUTPUT="${OUTPUT:-${IMAGE_NAME}${OUTPUT_SUFFIX}.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
|
||||
@@ -39,17 +58,10 @@ if [[ ! -d out ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The .dockerignore lives under docker/, but Docker reads it from the build
|
||||
# context root. Stage it for the duration of the build so node_modules/.next
|
||||
# don't get shipped into the build context.
|
||||
cleanup() { rm -f .dockerignore; }
|
||||
trap cleanup EXIT
|
||||
cp docker/.dockerignore .dockerignore
|
||||
|
||||
echo "==> Building ${IMAGE} for linux/amd64"
|
||||
docker build \
|
||||
--platform linux/amd64 \
|
||||
-f docker/Dockerfile \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE}" \
|
||||
.
|
||||
|
||||
@@ -60,13 +72,13 @@ echo "==> Done: ${OUTPUT} ($(du -h "${OUTPUT}" | cut -f1))"
|
||||
echo
|
||||
echo "Copy and run on the server:"
|
||||
echo " scp ${OUTPUT} user@your-server:/tmp/"
|
||||
echo " docker load < /tmp/${OUTPUT}"
|
||||
echo " docker load < /tmp/${OUTPUT##*/}"
|
||||
echo
|
||||
echo "Run (nginx serves on container port 80). Set the runtime env vars the"
|
||||
echo "Run (nginx serves on container port ${CONTAINER_PORT}). 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 " -p 8080:${CONTAINER_PORT} \\"
|
||||
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 \\"
|
||||
|
||||
+2
-1
@@ -24,5 +24,6 @@
|
||||
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
|
||||
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
|
||||
"hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID",
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS",
|
||||
"announcement": "$NETBIRD_ANNOUNCEMENT"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM alpine:3.24
|
||||
|
||||
RUN apk add --no-cache bash ca-certificates gettext nginx tzdata && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
mkdir -p /tmp/nginx
|
||||
|
||||
STOPSIGNAL SIGINT
|
||||
EXPOSE 8080
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY docker/default.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/rootless.conf /etc/nginx/http.d/rootless.conf
|
||||
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
|
||||
COPY out/ /usr/share/nginx/html/
|
||||
|
||||
RUN sed -i \
|
||||
-e 's/listen 80 default_server;/listen 8080 default_server;/' \
|
||||
-e 's/listen \[::\]:80 default_server;/listen [::]:8080 default_server;/' \
|
||||
/etc/nginx/http.d/default.conf && \
|
||||
sed -i '/^user nginx;$/d' /etc/nginx/nginx.conf && \
|
||||
chmod +x /usr/local/init_react_envs.sh && \
|
||||
chgrp -R 0 /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx && \
|
||||
chmod -R g=u /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx
|
||||
|
||||
USER 10001:0
|
||||
ENTRYPOINT ["/usr/local/init_react_envs.sh"]
|
||||
CMD ["/usr/sbin/nginx", "-e", "/dev/stderr", "-g", "pid /tmp/nginx.pid;"]
|
||||
+47
-11
@@ -1,23 +1,59 @@
|
||||
# NetBird Dashboard
|
||||
NetBird Dashboard is NetBirds Management server UI. It allows users to signin, view setup keys and manage peers. This image is **not ready** for production use.
|
||||
## Tags
|
||||
```latest``` ```vX.X.X``` not available yet.
|
||||
|
||||
```main``` built on every PR being merged to the repository
|
||||
NetBird Dashboard is NetBird's management server UI.
|
||||
|
||||
## Tags
|
||||
|
||||
Each published image tag also has a rootless variant with the `-rootless`
|
||||
suffix. For example, `main` is also published as `main-rootless`.
|
||||
|
||||
## How to use this image
|
||||
HTTP run:
|
||||
|
||||
HTTP:
|
||||
|
||||
```shell
|
||||
docker run -d --rm -p 80:80 wiretrustee/dashboard:main
|
||||
docker run -d --rm -p 80:80 netbirdio/dashboard:main
|
||||
```
|
||||
Using SSL certificate from Let's Encrypt®:
|
||||
|
||||
Using an SSL certificate from Let's Encrypt:
|
||||
|
||||
```shell
|
||||
docker run -d --rm -p 80:80 -p 443:443 \
|
||||
-e LETSENCRYPT_DOMAIN=app.mydomain.com \
|
||||
-e LETSENCRYPT_EMAIL=hello@mydomain.com \
|
||||
netbirdio/dashboard:main
|
||||
```
|
||||
> For SSL generation, you need to run this image in a server with proper public IP and a domain name pointing to it.
|
||||
|
||||
For certificate generation, the server needs a public IP and a domain name
|
||||
pointing to it.
|
||||
|
||||
### Rootless and OpenShift
|
||||
|
||||
The rootless image listens on port `8080`, runs as UID `10001` by default, and
|
||||
supports OpenShift's arbitrary UID when the container retains GID `0`:
|
||||
|
||||
```shell
|
||||
docker run -d --rm \
|
||||
--user 12345:0 \
|
||||
-p 8080:8080 \
|
||||
-e AUTH_AUTHORITY=https://idp.example.com \
|
||||
-e AUTH_CLIENT_ID=netbird-dashboard \
|
||||
-e AUTH_AUDIENCE=netbird-dashboard \
|
||||
-e AUTH_SUPPORTED_SCOPES='openid profile email' \
|
||||
-e USE_AUTH0=false \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=https://api.example.com \
|
||||
netbirdio/dashboard:main-rootless
|
||||
```
|
||||
|
||||
The rootless image does not include Certbot or expose HTTPS. Terminate TLS at
|
||||
the OpenShift Route, Ingress, or another reverse proxy.
|
||||
|
||||
## Environment variables
|
||||
* ```NGINX_SSL_PORT``` Changes the port that Nginx listens to. Defaults to ```443```
|
||||
* ```LETSENCRYPT_DOMAIN``` Enables Certbot`s client execution for the specified domain. Defaults to ```none```
|
||||
* ```LETSENCRYPT_EMAIL``` Email used in Certbot`s client execution to register the certificate request. Defaults to ```example@local```
|
||||
|
||||
- `NGINX_SSL_PORT` changes the port that Nginx listens to. Defaults to `443`.
|
||||
- `LETSENCRYPT_DOMAIN` enables Certbot for the specified domain. Defaults to
|
||||
`none`.
|
||||
- `LETSENCRYPT_EMAIL` is used to register the certificate request. Defaults to
|
||||
`example@local`.
|
||||
|
||||
These certificate variables apply only to the standard image.
|
||||
@@ -73,6 +73,8 @@ 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}
|
||||
# A permanent banner for this deployment, shown to every user; empty for none.
|
||||
export NETBIRD_ANNOUNCEMENT=${NETBIRD_ANNOUNCEMENT}
|
||||
|
||||
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
|
||||
@@ -163,7 +165,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_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"
|
||||
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 \$\$NETBIRD_ANNOUNCEMENT"
|
||||
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
@@ -173,6 +175,12 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
|
||||
rm "$f".copy
|
||||
done
|
||||
|
||||
# When a command is provided, initialization happens before the server starts.
|
||||
# The rootless image uses this path so nginx never needs to be reloaded.
|
||||
if (( $# > 0 )); then
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Reload nginx so the patched CSP header takes effect.
|
||||
# supervisord starts nginx (priority 100) before this script (priority 201) and
|
||||
# never reloads it, so without this nginx keeps serving the static default.conf
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# nginx includes this file inside the http context.
|
||||
# All runtime state stays in a directory writable by the arbitrary UID used by OpenShift.
|
||||
client_body_temp_path /tmp/nginx/client_temp;
|
||||
proxy_temp_path /tmp/nginx/proxy_temp;
|
||||
fastcgi_temp_path /tmp/nginx/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/nginx/uwsgi_temp;
|
||||
scgi_temp_path /tmp/nginx/scgi_temp;
|
||||
@@ -926,6 +926,16 @@ services:
|
||||
# GeoLite2 download removes a startup stall of up to 2 minutes
|
||||
# when pkgs.netbird.io is slow.
|
||||
- NB_PROXY_DISABLE_GEOLOCATION=true
|
||||
# Declare the private capability so this cluster can host the agent
|
||||
# network endpoint. That endpoint is a private service — reachable only
|
||||
# from connected peers — and management refuses to bootstrap it onto a
|
||||
# cluster without a private-capable proxy; the connect-provider wizard
|
||||
# reads the same flag (supports_private) to pick its cluster. Without
|
||||
# it no cluster qualifies and the wizard stops at the provider tab,
|
||||
# which strands every spec that walks through it. The no-ports proxy
|
||||
# below stays centralised, so the pick has to skip past a cluster that
|
||||
# does not qualify.
|
||||
- NB_PROXY_PRIVATE=true
|
||||
volumes:
|
||||
- ./proxy-certs:/certs:ro
|
||||
command: [
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export async function loginToApp(
|
||||
// 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 approvalPending = page.getByTestId("pending-approval");
|
||||
const onboarding = page.getByText("Add new device to your network");
|
||||
const selectAccount = page.getByText("Select account");
|
||||
const loginInput = page.locator("input[id=loginName]");
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Agent Network bootstrap-cluster spec.
|
||||
*
|
||||
* The account's agent network endpoint is assigned once, from the proxy
|
||||
* cluster the connect-provider wizard pins it to, and it is immutable after
|
||||
* that. Not every live cluster can host it: the endpoint is a private service,
|
||||
* reachable only from connected peers and authorised by their tunnel identity,
|
||||
* which only a cluster with private capabilities can serve. Management
|
||||
* reports that per cluster as supports_private and refuses a
|
||||
* bootstrap onto a cluster without it.
|
||||
*
|
||||
* This spec pins the wizard's half of that contract: it picks a
|
||||
* private-capable cluster even when a centralised one is listed first, and it
|
||||
* blocks setup — with a message naming what is missing — when no listed
|
||||
* cluster qualifies. Both the settings and domains routes are mocked, so the
|
||||
* assertions do not depend on which clusters the test backend happens to have
|
||||
* connected, and no provider is created against it.
|
||||
*/
|
||||
import { type Browser, expect, type Page, test } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
// Listed first, and unusable: a centralised proxy cluster. Picking it is the
|
||||
// bug this spec guards against.
|
||||
const CENTRAL_CLUSTER = "central.proxy.netbird.io";
|
||||
// Listed second, and the only valid pick.
|
||||
const PRIVATE_CLUSTER = "private.proxy.netbird.io";
|
||||
|
||||
const UNBOOTSTRAPPED_DEFAULTS = {
|
||||
endpoint: "",
|
||||
proxy_address: "",
|
||||
dedicated: false,
|
||||
enable_log_collection: true,
|
||||
enable_prompt_collection: false,
|
||||
redact_pii: false,
|
||||
access_log_retention_days: 30,
|
||||
};
|
||||
|
||||
const BOOTSTRAPPED_SETTINGS = {
|
||||
...UNBOOTSTRAPPED_DEFAULTS,
|
||||
endpoint: `violet.${PRIVATE_CLUSTER}`,
|
||||
proxy_address: PRIVATE_CLUSTER,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
/** A free (cluster-backed) domain as /reverse-proxies/domains reports it. */
|
||||
function freeDomain(name: string, supportsPrivate: boolean) {
|
||||
return {
|
||||
id: name,
|
||||
domain: name,
|
||||
validated: true,
|
||||
type: "free",
|
||||
supports_private: supportsPrivate,
|
||||
supports_custom_ports: true,
|
||||
require_subdomain: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function json(route: import("@playwright/test").Route, body: unknown) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the providers page for an account that has not been bootstrapped,
|
||||
* with the given clusters on the domains endpoint. Returns the page plus a
|
||||
* promise-friendly getter for the bootstrap POST body, which is captured and
|
||||
* answered locally so the real backend is never mutated.
|
||||
*/
|
||||
async function openWizard(
|
||||
browser: Browser,
|
||||
domains: ReturnType<typeof freeDomain>[],
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
try {
|
||||
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 page.route("**/api/agent-network/settings", (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") return json(route, UNBOOTSTRAPPED_DEFAULTS);
|
||||
if (method === "POST") return json(route, BOOTSTRAPPED_SETTINGS);
|
||||
return route.continue();
|
||||
});
|
||||
await page.route("**/api/reverse-proxies/domains", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? json(route, domains)
|
||||
: route.continue(),
|
||||
);
|
||||
// Keep the create hermetic: the wizard only reaches this once the
|
||||
// bootstrap it does first has succeeded.
|
||||
await page.route("**/api/agent-network/providers", (route) =>
|
||||
route.request().method() === "POST"
|
||||
? json(route, { id: "prov-e2e", name: "e2e-provider" })
|
||||
: route.continue(),
|
||||
);
|
||||
|
||||
await loginToApp(page, "owner");
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
return { page, close: () => context.close() };
|
||||
} catch (e) {
|
||||
await context.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fills the provider tab with the minimum a create needs. */
|
||||
async function fillProviderTab(page: Page) {
|
||||
await page
|
||||
.getByPlaceholder("https://api.openai.com")
|
||||
.fill("https://api.openai.com");
|
||||
await page.getByPlaceholder("sk-...").first().fill("sk-e2e-bootstrap-key");
|
||||
}
|
||||
|
||||
test.describe("Agent Network bootstrap cluster @agent-network", () => {
|
||||
test("bootstraps onto a private-capable cluster, skipping a centralised one listed first", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
freeDomain(PRIVATE_CLUSTER, true),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
// The unusable cluster is listed, but it is not a blocker: a usable one
|
||||
// exists, so the wizard proceeds without the warning.
|
||||
await expect(
|
||||
page.getByTestId("agent-network-no-cluster-callout"),
|
||||
).toHaveCount(0);
|
||||
|
||||
await fillProviderTab(page);
|
||||
|
||||
const bootstrap = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/agent-network/settings") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Continue" })
|
||||
.click({ force: true });
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.last()
|
||||
.click({ force: true });
|
||||
|
||||
const payload = (await bootstrap).request().postDataJSON();
|
||||
expect(payload.proxy_address).toBe(PRIVATE_CLUSTER);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks setup when no listed cluster has private capabilities", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
const callout = page.getByTestId("agent-network-no-cluster-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 10_000 });
|
||||
await expect(callout).toContainText("private capabilities");
|
||||
// The fix lives in the agent network's own Clusters section, not the
|
||||
// generic Reverse Proxy page, so that is where the callout sends users.
|
||||
await expect(callout.getByRole("link")).toHaveAttribute(
|
||||
"href",
|
||||
"/agent-network/configuration?tab=clusters",
|
||||
);
|
||||
|
||||
// With nothing to pin to there is no bootstrap to make, so the wizard
|
||||
// cannot advance — rather than sending a request management refuses.
|
||||
await fillProviderTab(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Continue" }),
|
||||
).toBeDisabled();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -102,7 +102,7 @@ async function loginAndSave(
|
||||
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 approval = page.getByTestId("pending-approval");
|
||||
|
||||
let after_login: "2fa" | "app" | "modal" | "approval";
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@ test.describe.serial("User Approval & Billing Admin @team", () => {
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await loginToApp(page, "user");
|
||||
await expect(page.getByText("User Approval Pending")).toBeVisible();
|
||||
await expect(page.getByTestId("pending-approval")).toBeVisible();
|
||||
await context.close();
|
||||
});
|
||||
|
||||
|
||||
Generated
+570
-463
File diff suppressed because it is too large.
Load diff
+8
-4
@@ -48,7 +48,7 @@
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "20.10.6",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-window": "^1.8.8",
|
||||
@@ -75,11 +75,10 @@
|
||||
"js-cookie": "^3.0.7",
|
||||
"lodash": "4.18.1",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "16.3.0",
|
||||
"next": "16.3.4",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-confetti-explosion": "^3.0.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-ga4": "^2.1.0",
|
||||
@@ -101,7 +100,6 @@
|
||||
"minimatch": ">=10.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -114,5 +112,11 @@
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@axa-fr/oidc-client": false,
|
||||
"@axa-fr/react-oidc": false,
|
||||
"fsevents": false,
|
||||
"unrs-resolver": false
|
||||
}
|
||||
}
|
||||
+15
-6
@@ -8,6 +8,7 @@ import { ArrowRightIcon, RefreshCw } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import { PendingApproval } from "@/modules/users/PendingApproval";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
@@ -57,9 +58,20 @@ export default function ErrorPage() {
|
||||
error?.code === 403 &&
|
||||
error?.message?.toLowerCase().includes("pending approval");
|
||||
|
||||
// Waiting for an approval is an expected part of signing up, so it gets a
|
||||
// welcoming screen of its own instead of the error treatment.
|
||||
if (isPendingApproval) {
|
||||
return (
|
||||
<PendingApproval
|
||||
error={error}
|
||||
onRefresh={handleRetry}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getTitle = () => {
|
||||
if (isBlockedUser) return "User Account Blocked";
|
||||
if (isPendingApproval) return "User Approval Pending";
|
||||
return "Access Error";
|
||||
};
|
||||
|
||||
@@ -67,9 +79,6 @@ export default function ErrorPage() {
|
||||
if (isBlockedUser) {
|
||||
return "Your access has been blocked by the NetBird account administrator, possibly due to new user approval requirements or security policies. Please contact your administrator to regain access.";
|
||||
}
|
||||
if (isPendingApproval) {
|
||||
return "Your account is pending approval from an administrator. Please wait for approval before accessing the dashboard.";
|
||||
}
|
||||
return "An error occurred while trying to access the dashboard. Please try again or contact your administrator.";
|
||||
};
|
||||
|
||||
@@ -98,7 +107,7 @@ export default function ErrorPage() {
|
||||
</Paragraph>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{!isBlockedUser && !isPendingApproval && (
|
||||
{!isBlockedUser && (
|
||||
<Button variant="default-outline" size="sm" onClick={handleRetry}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Try Again
|
||||
@@ -106,7 +115,7 @@ export default function ErrorPage() {
|
||||
)}
|
||||
|
||||
<Button variant="primary" size="sm" onClick={handleLogout}>
|
||||
{isBlockedUser || isPendingApproval ? "Sign Out" : "Logout"}
|
||||
{isBlockedUser ? "Sign Out" : "Logout"}
|
||||
<ArrowRightIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { faker } from "@faker-js/faker/locale/de";
|
||||
import { cn } from "@utils/helpers";
|
||||
import {
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
ChartData,
|
||||
ChartOptions,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "chart.js";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Line } from "react-chartjs-2";
|
||||
|
||||
type Props = {};
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Filler,
|
||||
Legend,
|
||||
);
|
||||
|
||||
export const options: ChartOptions<"line"> = {
|
||||
responsive: true,
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.4,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
grid: {
|
||||
color: "rgba(255,255,255,0.05)",
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
color: "rgba(255,255,255,1)",
|
||||
count: 3,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: "rgba(255,255,255,0.05)",
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const labels = [
|
||||
"0:00",
|
||||
"0:05",
|
||||
"0:10",
|
||||
"0:15",
|
||||
"0:20",
|
||||
"0:25",
|
||||
"0:30",
|
||||
"0:35",
|
||||
"0:40",
|
||||
"0:45",
|
||||
"0:50",
|
||||
"0:55",
|
||||
"1:00",
|
||||
];
|
||||
|
||||
export const TrafficEventChart = ({}: Props) => {
|
||||
const chartRef = React.useRef<ChartJS<"line">>(null);
|
||||
const [gradientColor, setGradientColor] = useState<CanvasGradient>();
|
||||
|
||||
React.useEffect(() => {
|
||||
const chart = chartRef.current;
|
||||
|
||||
if (!chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = chart.ctx;
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 150);
|
||||
gradient.addColorStop(0, "rgba(246,131,48,0.5)"); // Orange at the top
|
||||
gradient.addColorStop(1, "rgba(246,131,48,0)"); // Transparent at the bottom
|
||||
|
||||
setGradientColor(gradient);
|
||||
}, []);
|
||||
|
||||
const data: ChartData<"line"> = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
fill: true,
|
||||
data: labels.map(() =>
|
||||
faker.number.int({
|
||||
min: 40,
|
||||
max: 100,
|
||||
}),
|
||||
),
|
||||
borderColor: "rgba(246,131,48,0.8)",
|
||||
pointBackgroundColor: "rgba(246,131,48,1)",
|
||||
backgroundColor: gradientColor, // This will be replaced by the gradient
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={""}>
|
||||
<div
|
||||
className={cn(
|
||||
"border cursor-pointer border-nb-gray-900/80 bg-nb-gray-900/30 hover:bg-nb-gray-900/50 py-3 pl-3 pr-5 rounded-lg transition-all min-w-[310px] max-w-[400px]",
|
||||
)}
|
||||
>
|
||||
<Line
|
||||
options={options}
|
||||
data={data}
|
||||
ref={chartRef}
|
||||
height={700}
|
||||
width={1600}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
import { Modal, SidebarModalContent } from "@components/modal/Modal";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CheckIcon, HashIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { TrafficEventChart } from "@/cloud/traffic-events/misc/TrafficEventChart";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const TrafficEventDetails = ({ open, setOpen }: Props) => {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<SidebarModalContent
|
||||
maxWidthClass={"max-w-2xl"}
|
||||
showClose={true}
|
||||
className={"px-0 py-6 !bg-nb-gray-940"}
|
||||
>
|
||||
<div>
|
||||
<div className={"px-8 pb-6"}>
|
||||
<h1 className={"text-lg flex items-center gap-2"}>
|
||||
<HashIcon size={20} />
|
||||
NBSE2911929
|
||||
</h1>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className={"px-10 pt-6 mt-4"}>
|
||||
<ul>
|
||||
<ListItem>
|
||||
Connection successfully established between Peer 1 and Peer 2
|
||||
</ListItem>
|
||||
<ListItem>Peer 2 accepted the connection request</ListItem>
|
||||
|
||||
<ListItem>Posture Check XYZ passed</ListItem>
|
||||
<ListItem>Access Control Policy XYZ passed</ListItem>
|
||||
<ListItem hideLastLine={true}>
|
||||
Peer 1 requested to connect to Peer 2
|
||||
</ListItem>
|
||||
</ul>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className={"px-8 pb-6 pt-2"}>
|
||||
<ExpandedCards />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className={"px-8 py-6 "}>
|
||||
<div className={"grid grid-cols-3 gap-5"}></div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className={"px-8 py-6"}>
|
||||
<TrafficEventChart />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const ListItem = ({
|
||||
children,
|
||||
hideLastLine = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
hideLastLine?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex flex-col gap-2 items-start relative justify-center pl-6 pb-8 border-l-2 border-green-600",
|
||||
hideLastLine && "border-l-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-[17px] h-[17px] bg-nb-gray-925 rounded-full flex items-center justify-center shrink-0",
|
||||
"bg-green-600 text-green-100",
|
||||
"absolute left-0 -ml-[9.5px] -top-[10px]",
|
||||
)}
|
||||
>
|
||||
<CheckIcon size={11} />
|
||||
</div>
|
||||
<div className={"flex flex-col gap-1 relative -top-2"}>
|
||||
<span className={"text-xs text-nb-gray-300"}>
|
||||
Th, 27 February 2025, 16:08
|
||||
</span>
|
||||
<span className={"text-sm font-normal"}>{children}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const Separator = () => {
|
||||
return <div className={"h-[1px] bg-zinc-700/60 w-full"}></div>;
|
||||
};
|
||||
|
||||
const ExpandedCards = () => {
|
||||
return (
|
||||
<div className={"grid grid-cols-3 gap-4 mt-4"}>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col bg-nb-gray-920 px-5 py-3.5 rounded-md border border-nb-gray-910"
|
||||
}
|
||||
>
|
||||
<span className={"text-xs tracking-wide text-nb-gray-300 mb-0.5"}>
|
||||
Duration
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-sm font-medium text-nb-gray-200 flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{"< "}
|
||||
10 minutes
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col bg-nb-gray-920 px-5 py-3.5 rounded-md border border-nb-gray-910"
|
||||
}
|
||||
>
|
||||
<span className={"text-xs tracking-wide text-nb-gray-300 mb-0.5"}>
|
||||
Inbound Traffic
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-sm font-medium text-nb-gray-200 flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<ArrowDownIcon size={16} className={"text-sky-400"} />
|
||||
15.2 GB
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col bg-nb-gray-920 px-5 py-3.5 rounded-md border border-nb-gray-910"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={"text-xs tracking-wide text-nb-gray-300 mb-0.5 flex gap-2"}
|
||||
>
|
||||
Outbound Traffic
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-sm font-medium text-nb-gray-200 flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<ArrowUpIcon size={16} className={"text-netbird-500"} />
|
||||
10.8 GB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,9 @@ type Props = {
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
/** Lets callers mark a callout so tests can assert on the state it
|
||||
* announces rather than on its copy. */
|
||||
"data-testid"?: string;
|
||||
} & CalloutVariants;
|
||||
|
||||
export const calloutVariants = cva(
|
||||
@@ -31,9 +34,13 @@ export const Callout = ({
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />,
|
||||
className,
|
||||
variant = "default",
|
||||
"data-testid": dataTestId,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={cn(calloutVariants({ variant }), className)}>
|
||||
<div
|
||||
className={cn(calloutVariants({ variant }), className)}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{icon}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -18,13 +18,34 @@ export default function Steps({
|
||||
);
|
||||
}
|
||||
|
||||
// Steps without a status keep the neutral look of the instruction-list
|
||||
// steppers, where no step is ever "reached".
|
||||
type StepStatus = "complete" | "current" | "upcoming";
|
||||
|
||||
// The rail has to meet the middle of the circle, so its offset is half the
|
||||
// circle and moves with the size.
|
||||
const stepSizes = {
|
||||
default: {
|
||||
circle: "h-[34px] w-[34px]",
|
||||
railHorizontal: "mt-[16px]",
|
||||
railVertical: "ml-[18px]",
|
||||
},
|
||||
large: {
|
||||
circle: "h-[44px] w-[44px]",
|
||||
railHorizontal: "mt-[21px]",
|
||||
railVertical: "ml-[23px]",
|
||||
},
|
||||
};
|
||||
|
||||
type StepProps = {
|
||||
children: React.ReactNode;
|
||||
step: number;
|
||||
step: React.ReactNode;
|
||||
line?: boolean;
|
||||
center?: boolean;
|
||||
horizontal?: boolean;
|
||||
disabled?: boolean;
|
||||
status?: StepStatus;
|
||||
size?: keyof typeof stepSizes;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -35,8 +56,12 @@ const Step = ({
|
||||
center = false,
|
||||
horizontal,
|
||||
disabled = false,
|
||||
status,
|
||||
size = "default",
|
||||
className,
|
||||
}: StepProps) => {
|
||||
const sizing = stepSizes[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -52,18 +77,31 @@ const Step = ({
|
||||
className={cn(
|
||||
"bg-nb-gray-100 dark:bg-nb-gray-800 z-0 transition-all",
|
||||
horizontal
|
||||
? "w-full h-[2px] absolute mt-[16px] transform translate-x-1/2"
|
||||
: "h-full w-[2px] absolute left-0 ml-[18px]",
|
||||
? cn(
|
||||
"w-full h-[2px] absolute transform translate-x-1/2",
|
||||
sizing.railHorizontal,
|
||||
)
|
||||
: cn("h-full w-[2px] absolute left-0", sizing.railVertical),
|
||||
// The line trails its step, so a completed step also means the hop
|
||||
// to the next one is behind us.
|
||||
status === "complete" && "bg-netbird dark:bg-netbird",
|
||||
)}
|
||||
></span>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"h-[34px] w-[34px] shrink-0 rounded-full flex items-center justify-center font-medium text-xs relative z-0 border-4 transition-all",
|
||||
"dark:bg-nb-gray-900 dark:text-nb-gray-400 dark:border-nb-gray dark:group-hover:bg-nb-gray-800",
|
||||
"bg-nb-gray-100 text-nb-gray-400 border-white group-hover:bg-nb-gray-200 step-circle",
|
||||
"shrink-0 rounded-full flex items-center justify-center font-medium text-xs relative z-0 border-4 transition-all",
|
||||
sizing.circle,
|
||||
"dark:bg-nb-gray-900 dark:text-nb-gray-400 dark:border-nb-gray",
|
||||
"bg-nb-gray-100 text-nb-gray-400 border-white step-circle",
|
||||
"[.stepper-bg-variant]:border-nb-gray-940",
|
||||
!status &&
|
||||
"group-hover:bg-nb-gray-200 dark:group-hover:bg-nb-gray-800",
|
||||
status && "border-white dark:border-nb-gray-940",
|
||||
status === "complete" &&
|
||||
"bg-netbird text-white dark:bg-netbird dark:text-white",
|
||||
status === "current" && "text-nb-gray-800 dark:text-white",
|
||||
)}
|
||||
>
|
||||
{step}
|
||||
|
||||
Loaded 20 of 63 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user