mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a3fc271da |
No files matched your search
@@ -98,24 +98,6 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Docker meta (rootless)
|
||||
id: meta_rootless
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_IMAGE }}
|
||||
${{ env.GHCR_IMAGE }}
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=-rootless,onlatest=true
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@v3
|
||||
@@ -141,18 +123,7 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push rootless Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.rootless
|
||||
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm
|
||||
tags: ${{ steps.meta_rootless.outputs.tags }}
|
||||
labels: ${{ steps.meta_rootless.outputs.labels }}
|
||||
|
||||
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
|
||||
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
|
||||
echo '${{ steps.meta_rootless.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
|
||||
+2
-7
@@ -51,11 +51,6 @@ playwright-report*.zip
|
||||
.configs
|
||||
|
||||
/public/ironrdp-pkg/
|
||||
/public/netbird.wasm*
|
||||
/public/netbird.wasm
|
||||
.idea
|
||||
src/.local-config*
|
||||
.deepsec/
|
||||
|
||||
|
||||
# Local SDK tarballs from the agent repo (packed by scripts, not committed)
|
||||
vendor/
|
||||
src/.local-config*
|
||||
@@ -11,7 +11,6 @@
|
||||
# ./build.sh # build + export netbird-dashboard.tar.gz
|
||||
# IMAGE_TAG=v1 ./build.sh # override the image tag
|
||||
# SKIP_BUILD=1 ./build.sh # reuse an existing out/ (skip npm build)
|
||||
# ROOTLESS=1 ./build.sh # build the amd64-rootless image variant
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
@@ -20,26 +19,8 @@ cd "$(dirname "$0")"
|
||||
|
||||
IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-amd64}"
|
||||
DOCKERFILE="docker/Dockerfile"
|
||||
CONTAINER_PORT=80
|
||||
OUTPUT_SUFFIX=""
|
||||
|
||||
case "${ROOTLESS:-0}" in
|
||||
0) ;;
|
||||
1)
|
||||
DOCKERFILE="docker/Dockerfile.rootless"
|
||||
CONTAINER_PORT=8080
|
||||
OUTPUT_SUFFIX="-rootless"
|
||||
[[ "${IMAGE_TAG}" == *-rootless ]] || IMAGE_TAG="${IMAGE_TAG}-rootless"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: ROOTLESS must be 0 or 1." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
OUTPUT="${OUTPUT:-${IMAGE_NAME}${OUTPUT_SUFFIX}.tar.gz}"
|
||||
OUTPUT="${OUTPUT:-${IMAGE_NAME}.tar.gz}"
|
||||
|
||||
# Build the static export (out/) unless told to reuse an existing one. Next 16
|
||||
# no longer runs ESLint during `next build`; types are checked by the build via
|
||||
@@ -58,10 +39,17 @@ if [[ ! -d out ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The .dockerignore lives under docker/, but Docker reads it from the build
|
||||
# context root. Stage it for the duration of the build so node_modules/.next
|
||||
# don't get shipped into the build context.
|
||||
cleanup() { rm -f .dockerignore; }
|
||||
trap cleanup EXIT
|
||||
cp docker/.dockerignore .dockerignore
|
||||
|
||||
echo "==> Building ${IMAGE} for linux/amd64"
|
||||
docker build \
|
||||
--platform linux/amd64 \
|
||||
-f "${DOCKERFILE}" \
|
||||
-f docker/Dockerfile \
|
||||
-t "${IMAGE}" \
|
||||
.
|
||||
|
||||
@@ -72,13 +60,13 @@ echo "==> Done: ${OUTPUT} ($(du -h "${OUTPUT}" | cut -f1))"
|
||||
echo
|
||||
echo "Copy and run on the server:"
|
||||
echo " scp ${OUTPUT} user@your-server:/tmp/"
|
||||
echo " docker load < /tmp/${OUTPUT##*/}"
|
||||
echo " docker load < /tmp/${OUTPUT}"
|
||||
echo
|
||||
echo "Run (nginx serves on container port ${CONTAINER_PORT}). Set the runtime env vars the"
|
||||
echo "Run (nginx serves on container port 80). Set the runtime env vars the"
|
||||
echo "dashboard templates at startup — for the Agent Network onboarding test,"
|
||||
echo "flip NETBIRD_AGENT_NETWORK_ONLY=true:"
|
||||
echo " docker run -d --name ${IMAGE_NAME} --restart unless-stopped \\"
|
||||
echo " -p 8080:${CONTAINER_PORT} \\"
|
||||
echo " -p 8080:80 \\"
|
||||
echo " -e NETBIRD_MGMT_API_ENDPOINT=https://your-mgmt:443 \\"
|
||||
echo " -e AUTH_AUTHORITY=https://your-idp/realms/netbird \\"
|
||||
echo " -e AUTH_CLIENT_ID=netbird-dashboard \\"
|
||||
|
||||
+1
-2
@@ -24,6 +24,5 @@
|
||||
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
|
||||
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
|
||||
"hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID",
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS",
|
||||
"assistantApiOrigin": "$NETBIRD_ASSISTANT_API_ENDPOINT"
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
}
|
||||
File renamed without changes.
@@ -1,29 +0,0 @@
|
||||
FROM alpine:3.24
|
||||
|
||||
RUN apk add --no-cache bash ca-certificates gettext nginx tzdata && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
mkdir -p /tmp/nginx
|
||||
|
||||
STOPSIGNAL SIGINT
|
||||
EXPOSE 8080
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY docker/default.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/rootless.conf /etc/nginx/http.d/rootless.conf
|
||||
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
|
||||
COPY out/ /usr/share/nginx/html/
|
||||
|
||||
RUN sed -i \
|
||||
-e 's/listen 80 default_server;/listen 8080 default_server;/' \
|
||||
-e 's/listen \[::\]:80 default_server;/listen [::]:8080 default_server;/' \
|
||||
/etc/nginx/http.d/default.conf && \
|
||||
sed -i '/^user nginx;$/d' /etc/nginx/nginx.conf && \
|
||||
chmod +x /usr/local/init_react_envs.sh && \
|
||||
chgrp -R 0 /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx && \
|
||||
chmod -R g=u /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx
|
||||
|
||||
USER 10001:0
|
||||
ENTRYPOINT ["/usr/local/init_react_envs.sh"]
|
||||
CMD ["/usr/sbin/nginx", "-e", "/dev/stderr", "-g", "pid /tmp/nginx.pid;"]
|
||||
+10
-46
@@ -1,59 +1,23 @@
|
||||
# NetBird Dashboard
|
||||
|
||||
NetBird Dashboard is NetBird's management server UI.
|
||||
|
||||
NetBird Dashboard is NetBirds Management server UI. It allows users to signin, view setup keys and manage peers. This image is **not ready** for production use.
|
||||
## Tags
|
||||
```latest``` ```vX.X.X``` not available yet.
|
||||
|
||||
Each published image tag also has a rootless variant with the `-rootless`
|
||||
suffix. For example, `main` is also published as `main-rootless`.
|
||||
|
||||
```main``` built on every PR being merged to the repository
|
||||
## How to use this image
|
||||
|
||||
HTTP:
|
||||
|
||||
HTTP run:
|
||||
```shell
|
||||
docker run -d --rm -p 80:80 netbirdio/dashboard:main
|
||||
docker run -d --rm -p 80:80 wiretrustee/dashboard:main
|
||||
```
|
||||
|
||||
Using an SSL certificate from Let's Encrypt:
|
||||
|
||||
Using SSL certificate from Let's Encrypt®:
|
||||
```shell
|
||||
docker run -d --rm -p 80:80 -p 443:443 \
|
||||
-e LETSENCRYPT_DOMAIN=app.mydomain.com \
|
||||
-e LETSENCRYPT_EMAIL=hello@mydomain.com \
|
||||
netbirdio/dashboard:main
|
||||
```
|
||||
|
||||
For certificate generation, the server needs a public IP and a domain name
|
||||
pointing to it.
|
||||
|
||||
### Rootless and OpenShift
|
||||
|
||||
The rootless image listens on port `8080`, runs as UID `10001` by default, and
|
||||
supports OpenShift's arbitrary UID when the container retains GID `0`:
|
||||
|
||||
```shell
|
||||
docker run -d --rm \
|
||||
--user 12345:0 \
|
||||
-p 8080:8080 \
|
||||
-e AUTH_AUTHORITY=https://idp.example.com \
|
||||
-e AUTH_CLIENT_ID=netbird-dashboard \
|
||||
-e AUTH_AUDIENCE=netbird-dashboard \
|
||||
-e AUTH_SUPPORTED_SCOPES='openid profile email' \
|
||||
-e USE_AUTH0=false \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=https://api.example.com \
|
||||
netbirdio/dashboard:main-rootless
|
||||
```
|
||||
|
||||
The rootless image does not include Certbot or expose HTTPS. Terminate TLS at
|
||||
the OpenShift Route, Ingress, or another reverse proxy.
|
||||
|
||||
> For SSL generation, you need to run this image in a server with proper public IP and a domain name pointing to it.
|
||||
## Environment variables
|
||||
|
||||
- `NGINX_SSL_PORT` changes the port that Nginx listens to. Defaults to `443`.
|
||||
- `LETSENCRYPT_DOMAIN` enables Certbot for the specified domain. Defaults to
|
||||
`none`.
|
||||
- `LETSENCRYPT_EMAIL` is used to register the certificate request. Defaults to
|
||||
`example@local`.
|
||||
|
||||
These certificate variables apply only to the standard image.
|
||||
* ```NGINX_SSL_PORT``` Changes the port that Nginx listens to. Defaults to ```443```
|
||||
* ```LETSENCRYPT_DOMAIN``` Enables Certbot`s client execution for the specified domain. Defaults to ```none```
|
||||
* ```LETSENCRYPT_EMAIL``` Email used in Certbot`s client execution to register the certificate request. Defaults to ```example@local```
|
||||
@@ -73,8 +73,6 @@ 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}
|
||||
# No default: an unset endpoint is what turns the assistant off.
|
||||
export NETBIRD_ASSISTANT_API_ENDPOINT=${NETBIRD_ASSISTANT_API_ENDPOINT}
|
||||
|
||||
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
|
||||
@@ -123,12 +121,6 @@ if [[ -n "${NETBIRD_MGMT_API_ENDPOINT}" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add NETBIRD_ASSISTANT_API_ENDPOINT to CSP — the browser streams the chat
|
||||
# straight from this origin, so without it connect-src blocks every request.
|
||||
if [[ -n "${NETBIRD_ASSISTANT_API_ENDPOINT}" ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_ASSISTANT_API_ENDPOINT"
|
||||
fi
|
||||
|
||||
# Add LETSENCRYPT_DOMAIN to CSP
|
||||
if [[ -n "${LETSENCRYPT_DOMAIN}" ]]; then
|
||||
if [[ "$LETSENCRYPT_DOMAIN" == *"localhost"* ]]; then
|
||||
@@ -171,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_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_ASSISTANT_API_ENDPOINT"
|
||||
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"
|
||||
@@ -181,12 +173,6 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
|
||||
rm "$f".copy
|
||||
done
|
||||
|
||||
# When a command is provided, initialization happens before the server starts.
|
||||
# The rootless image uses this path so nginx never needs to be reloaded.
|
||||
if (( $# > 0 )); then
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Reload nginx so the patched CSP header takes effect.
|
||||
# supervisord starts nginx (priority 100) before this script (priority 201) and
|
||||
# never reloads it, so without this nginx keeps serving the static default.conf
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# nginx includes this file inside the http context.
|
||||
# All runtime state stays in a directory writable by the arbitrary UID used by OpenShift.
|
||||
client_body_temp_path /tmp/nginx/client_temp;
|
||||
proxy_temp_path /tmp/nginx/proxy_temp;
|
||||
fastcgi_temp_path /tmp/nginx/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/nginx/uwsgi_temp;
|
||||
scgi_temp_path /tmp/nginx/scgi_temp;
|
||||
@@ -926,16 +926,6 @@ 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: [
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function resetDraftState(page: Page) {
|
||||
export async function readDraftChanges(page: Page): Promise<any[]> {
|
||||
return await page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __controlCenterDraftChanges?: any[] }).__controlCenterDraftChanges ??
|
||||
(window as unknown as { __ccDraftChanges?: any[] }).__ccDraftChanges ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -212,7 +212,7 @@ export async function createViaCanvasMenu(
|
||||
export async function readDraftCanvas(page: Page): Promise<any | null> {
|
||||
return await page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __controlCenterDraftCanvas?: unknown }).__controlCenterDraftCanvas ??
|
||||
(window as unknown as { __ccDraftCanvas?: unknown }).__ccDraftCanvas ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+4
-20
@@ -1,11 +1,3 @@
|
||||
/**
|
||||
* ESLint flat config.
|
||||
*
|
||||
* Replaces `.eslintrc.json`, which ESLint 9 no longer reads by default, and
|
||||
* `next lint`, which Next 16 removed. `eslint-config-next` v16 already exports
|
||||
* flat config arrays, so this is a direct translation of the old eslintrc rather
|
||||
* than a FlatCompat shim.
|
||||
*/
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import prettier from "eslint-config-prettier";
|
||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||
@@ -13,31 +5,23 @@ import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||
const config = [
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
".claude/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"public/**",
|
||||
"playwright-report/**",
|
||||
"test-logs/**",
|
||||
"next-env.d.ts",
|
||||
"public/**",
|
||||
],
|
||||
},
|
||||
|
||||
...nextCoreWebVitals,
|
||||
|
||||
// Must come after the Next config so it can switch off stylistic rules that
|
||||
// would otherwise fight Prettier.
|
||||
prettier,
|
||||
|
||||
{
|
||||
files: ["**/*.{js,jsx,mjs,ts,tsx,mts,cts}"],
|
||||
plugins: { "simple-import-sort": simpleImportSort },
|
||||
rules: {
|
||||
"simple-import-sort/imports": [
|
||||
"warn",
|
||||
{ groups: [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]] },
|
||||
{
|
||||
groups: [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]],
|
||||
},
|
||||
],
|
||||
"simple-import-sort/exports": "warn",
|
||||
},
|
||||
|
||||
@@ -10,10 +10,6 @@ const nextConfig = {
|
||||
NEXT_PUBLIC_DASHBOARD_VERSION:
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development",
|
||||
},
|
||||
// The assistant SDKs are consumed as packed tarballs under ./vendor, so
|
||||
// they live inside node_modules like any other dependency; they ship
|
||||
// prebuilt ESM with "use client" banners, which Next consumes directly.
|
||||
transpilePackages: ["@netbird/assistant-react"],
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
Generated
+959
-3236
File diff suppressed because it is too large.
Load diff
+4
-14
@@ -23,11 +23,8 @@
|
||||
"test:ci": "cross-env APP_ENV=test next build && npx playwright test --config=e2e/playwright.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.15.8",
|
||||
"@assistant-ui/react-markdown": "^0.14.9",
|
||||
"@axa-fr/react-oidc": "^7.26.3",
|
||||
"@dagrejs/dagre": "^1.1.5",
|
||||
"@netbird/assistant-react": "file:vendor/netbird-assistant-react-0.1.0.tgz",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -51,7 +48,7 @@
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node": "20.10.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-window": "^1.8.8",
|
||||
@@ -74,16 +71,15 @@
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"framer-motion": "^12.29.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ip-address": "^10.2.0",
|
||||
"js-cookie": "^3.0.7",
|
||||
"lodash": "4.18.1",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "16.3.4",
|
||||
"next": "16.3.0",
|
||||
"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",
|
||||
@@ -94,7 +90,6 @@
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-virtuoso": "^4.9.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.2.4",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
@@ -106,6 +101,7 @@
|
||||
"minimatch": ">=10.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -118,11 +114,5 @@
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@axa-fr/oidc-client": false,
|
||||
"@axa-fr/react-oidc": false,
|
||||
"fsevents": false,
|
||||
"unrs-resolver": false
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
|
||||
import { CanvasAgentBridge } from "@/modules/control-center/agent/CanvasAgentBridge";
|
||||
import {
|
||||
CanvasStateProvider,
|
||||
ControlCenterUIProvider,
|
||||
@@ -49,7 +48,6 @@ import { PeersToolbar } from "@/modules/control-center/draft/PeersToolbar";
|
||||
import { ConnectionLine } from "@/modules/control-center/edges/ConnectionLine";
|
||||
import { ControlCenterEmptyStates } from "@/modules/control-center/header/ControlCenterEmptyStates";
|
||||
import { ControlCenterHeader } from "@/modules/control-center/header/ControlCenterHeader";
|
||||
import { useAssistantPanelPan } from "@/modules/control-center/hooks/useAssistantPanelPan";
|
||||
import { useDraft } from "@/modules/control-center/hooks/useDraft";
|
||||
import { useDragToGroup } from "@/modules/control-center/hooks/useDragToGroup";
|
||||
import { useDrillDownBrowserHistory } from "@/modules/control-center/hooks/useDrillDownBrowserHistory";
|
||||
@@ -118,7 +116,6 @@ function ControlCenterCanvas() {
|
||||
const { onNodeDragStart, onNodeDrag, onNodeDragStop } = useDragToGroup();
|
||||
useDrillDownBrowserHistory();
|
||||
useGroupFocusDim();
|
||||
useAssistantPanelPan();
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const onLiveNetworkCreated = React.useCallback(
|
||||
@@ -256,8 +253,6 @@ function ControlCenterCanvas() {
|
||||
<DraftNetworkDestinationModal />
|
||||
<DraftNetworkEditModal />
|
||||
<DraftLeaveGuard />
|
||||
{/* Publishes the canvas to the assistant for as long as this page is up. */}
|
||||
<CanvasAgentBridge onConnect={draft.onNodeConnect} />
|
||||
<ReactFlow
|
||||
className={highlightArmed ? "cc-focus-armed" : undefined}
|
||||
edges={canvas.edges}
|
||||
|
||||
@@ -70,6 +70,7 @@ import ReverseProxiesProvider, {
|
||||
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
|
||||
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
|
||||
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
|
||||
import { isSSHSupportedOnOS } from "@/modules/remote-access/osSupport";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
|
||||
@@ -430,6 +431,7 @@ const PeerOverviewTabContent = () => {
|
||||
const { peer } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const { selectedGroups, setSelectedGroups } = usePeerSettings();
|
||||
const isSSHSupported = isSSHSupportedOnOS(peer?.os);
|
||||
|
||||
return (
|
||||
<div className={"px-8"}>
|
||||
@@ -458,11 +460,14 @@ const PeerOverviewTabContent = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PeerSSHToggle />
|
||||
{isSSHSupported && <PeerSSHToggle />}
|
||||
|
||||
<div>
|
||||
<Label>Remote Access</Label>
|
||||
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
|
||||
<HelpText>
|
||||
Connect directly to this peer via{" "}
|
||||
{isSSHSupported ? "SSH or RDP" : "RDP"}.
|
||||
</HelpText>
|
||||
<div className="flex gap-3">
|
||||
<SSHButton peer={peer} />
|
||||
<RDPButton peer={peer} />
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useOidc } from "@axa-fr/react-oidc";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { CheckCircle2Icon, CircleXIcon, Loader2Icon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ACCESS_GRANTED_MESSAGE,
|
||||
type AccessGrantedMessage,
|
||||
consumeAccessNonce,
|
||||
hasAccessNonce,
|
||||
isAllowedAccessRule,
|
||||
} from "@/modules/assistant/assistantAccessAuth";
|
||||
|
||||
/**
|
||||
* Authorizes the assistant to reach one peer, in a window the user opened.
|
||||
*
|
||||
* ## Why this is a page and not a fetch
|
||||
*
|
||||
* Everything here could be done by the assistant panel directly — it has the
|
||||
* user's bearer already. The point is that it is NOT: the grant is created by a
|
||||
* window a person opened and signed in to. A silent token refresh proves a
|
||||
* refresh token exists in storage; this proves someone is at the keyboard.
|
||||
*
|
||||
* ## Why a sign-in is not enough on its own
|
||||
*
|
||||
* Everything below arrives in the URL, and a page that turns URL parameters
|
||||
* into SSH access is a page anyone can write a link to. The sign-in proves a
|
||||
* person is present; it does not prove they meant to grant THIS key access to
|
||||
* THIS machine, and a link mailed to an admin looks from here exactly like a
|
||||
* request the assistant made — same origin, same familiar sign-in.
|
||||
*
|
||||
* So two things are checked before anything is granted. The nonce the opener
|
||||
* left in `sessionStorage`, which a window opened from the panel inherits and
|
||||
* a window opened from a link does not; and the rules, against the set this
|
||||
* flow is allowed to ask for, since `tcp/3389` would be RDP rather than SSH.
|
||||
*
|
||||
* ## Why it forces a fresh sign-in
|
||||
*
|
||||
* Sitting behind the authenticated layout is not enough, and this is the part
|
||||
* that is easy to get wrong. With a live dashboard session the guard
|
||||
* authenticates silently, the window flashes "granted", and nobody has proved
|
||||
* anything — the check degrades to "a window was opened", which a background
|
||||
* tab could have done. `prompt=login` makes the identity provider ask again,
|
||||
* every time, which is the whole reason this window exists.
|
||||
*
|
||||
* ## What it grants
|
||||
*
|
||||
* Exactly what the opener asked for, for one peer: a temporary-access peer
|
||||
* carrying the assistant tab's OWN WireGuard public key, so the grant lands on
|
||||
* the peer the assistant is already running rather than on a second one that
|
||||
* would die with this window. The key arrives in the URL and is public by
|
||||
* definition — the private half stays in the assistant's tab and is never part
|
||||
* of this flow.
|
||||
*/
|
||||
|
||||
/** Marks the return trip, so the forced sign-in happens once and not in a loop. */
|
||||
const REAUTH_MARKER = "reauth";
|
||||
|
||||
/**
|
||||
* How long the success state stays up before the window closes itself.
|
||||
*
|
||||
* Not zero. This window is the only place the grant is confirmed and the only
|
||||
* place the machine it covers is named, and one that vanishes the instant it
|
||||
* appears reads as one that failed. The beat also keeps the reply ahead of the
|
||||
* close: the grant is reported to the opener by `postMessage`, and leaving a
|
||||
* gap means nothing here depends on how a browser orders a queued message
|
||||
* against a window that is going away.
|
||||
*/
|
||||
const AUTO_CLOSE_MS = 1_200;
|
||||
|
||||
/**
|
||||
* How long after the close attempt to conclude the browser refused it.
|
||||
*
|
||||
* A script-opened window may normally close itself, but that is a permission
|
||||
* and not a guarantee. If this timer still runs, the close did not happen and
|
||||
* the person is looking at a window with no way out of it.
|
||||
*/
|
||||
const CLOSE_REFUSED_MS = 400;
|
||||
|
||||
export default function AssistantAccessPage() {
|
||||
const params = useSearchParams();
|
||||
const { login } = useOidc();
|
||||
const peerRequest = useApiCall("/peers");
|
||||
const [state, setState] = useState<"working" | "granted" | "failed">(
|
||||
"working",
|
||||
);
|
||||
const [error, setError] = useState("");
|
||||
const [closeRefused, setCloseRefused] = useState(false);
|
||||
// React mounts effects twice in dev; a double grant would create a second
|
||||
// policy for the same peer, and a double login would bounce the window.
|
||||
const started = useRef(false);
|
||||
|
||||
const peerId = params.get("peer");
|
||||
const peerLabel = params.get("label") ?? "this peer";
|
||||
const wgPublicKey = params.get("key");
|
||||
const assistantPeerName = params.get("name");
|
||||
const rules = (params.get("rules") ?? "").split(",").filter(Boolean);
|
||||
const nonce = params.get("nonce");
|
||||
const reauthenticated = params.get(REAUTH_MARKER) === "1";
|
||||
|
||||
useEffect(() => {
|
||||
if (started.current) return;
|
||||
started.current = true;
|
||||
|
||||
const reply = (message: AccessGrantedMessage) => {
|
||||
// Targeted at this origin rather than "*", so the grant result is not
|
||||
// broadcast to whatever else may be listening.
|
||||
window.opener?.postMessage(message, window.location.origin);
|
||||
};
|
||||
|
||||
const refuse = (message: string) => {
|
||||
setState("failed");
|
||||
setError(message);
|
||||
// The opener is told, so the tool settles now rather than waiting out
|
||||
// its timeout. Cross-origin openers never receive this: `reply` targets
|
||||
// this origin, so a window opened from somewhere else hears nothing.
|
||||
if (peerId) {
|
||||
reply({
|
||||
type: ACCESS_GRANTED_MESSAGE,
|
||||
ok: false,
|
||||
peerId,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (!peerId || !wgPublicKey || !assistantPeerName || rules.length === 0) {
|
||||
setState("failed");
|
||||
setError("This link is missing what it needs to authorize anything.");
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
Checked before the sign-in, not after: a link that gets as far as an
|
||||
identity provider has already borrowed the credibility of one, and the
|
||||
person is answering a prompt they have no reason to distrust.
|
||||
*/
|
||||
if (!hasAccessNonce(nonce)) {
|
||||
refuse(
|
||||
"This window was not opened by the assistant, so nothing was authorized. " +
|
||||
"Start the request from the assistant panel in the dashboard.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rules.every(isAllowedAccessRule)) {
|
||||
refuse(
|
||||
"This link asks for access this flow does not grant, so nothing was authorized.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
Ask the identity provider to authenticate the person again, and come
|
||||
back here afterwards. The marker is what stops this repeating: without
|
||||
it the return trip would be indistinguishable from the first visit and
|
||||
the window would bounce to the IdP forever.
|
||||
*/
|
||||
if (!reauthenticated) {
|
||||
const back = `${window.location.pathname}${window.location.search}&${REAUTH_MARKER}=1`;
|
||||
await login(back, { prompt: "login" });
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
Spent on the way past, so a reload or a Back inside this window cannot
|
||||
grant a second time. The opener keeps its own copy, so this settles
|
||||
nothing there.
|
||||
*/
|
||||
if (!consumeAccessNonce(nonce)) {
|
||||
refuse(
|
||||
"This window has already been used, so nothing was authorized. " +
|
||||
"Start the request from the assistant panel in the dashboard.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await peerRequest.post(
|
||||
{ name: assistantPeerName, wg_pub_key: wgPublicKey, rules },
|
||||
`/${encodeURIComponent(peerId)}/temporary-access`,
|
||||
);
|
||||
setState("granted");
|
||||
reply({ type: ACCESS_GRANTED_MESSAGE, ok: true, peerId });
|
||||
} catch (err) {
|
||||
const detail =
|
||||
err instanceof Error ? err.message : "The request was refused.";
|
||||
setState("failed");
|
||||
setError(detail);
|
||||
reply({
|
||||
type: ACCESS_GRANTED_MESSAGE,
|
||||
ok: false,
|
||||
peerId,
|
||||
error: detail,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
run().catch(() => {
|
||||
setState("failed");
|
||||
setError("Something went wrong while authorizing.");
|
||||
});
|
||||
// Once, on mount. The parameters come from the URL and do not change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Granting is the end of this window's job, so it sees itself out. The
|
||||
// opener has already been told; nothing here is waiting on a click.
|
||||
useEffect(() => {
|
||||
if (state !== "granted") return;
|
||||
const closing = window.setTimeout(() => window.close(), AUTO_CLOSE_MS);
|
||||
const refused = window.setTimeout(
|
||||
() => setCloseRefused(true),
|
||||
AUTO_CLOSE_MS + CLOSE_REFUSED_MS,
|
||||
);
|
||||
return () => {
|
||||
window.clearTimeout(closing);
|
||||
window.clearTimeout(refused);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-nb-gray-950 px-8">
|
||||
<div className="flex max-w-md flex-col items-center gap-4 text-center">
|
||||
{state === "working" && (
|
||||
<>
|
||||
<Loader2Icon className="animate-spin text-netbird" size={32} />
|
||||
<h1 className="text-lg font-medium text-nb-gray-100">
|
||||
{reauthenticated ? "Granting access" : "Confirming it's you"}
|
||||
</h1>
|
||||
<p className="text-sm text-nb-gray-300">
|
||||
{reauthenticated
|
||||
? `Giving the assistant temporary SSH access to ${peerLabel}.`
|
||||
: "Signing you in again before anything is granted."}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "granted" && (
|
||||
<>
|
||||
<CheckCircle2Icon className="text-green-500" size={32} />
|
||||
<h1 className="text-lg font-medium text-nb-gray-100">
|
||||
Successfully Authorized
|
||||
</h1>
|
||||
{/* Names the machine: this is the one moment the person is shown
|
||||
what they just granted access to. */}
|
||||
<p className="text-sm text-nb-gray-300">
|
||||
The access covers {peerLabel} only, and ends when the assistant
|
||||
disconnects.
|
||||
</p>
|
||||
{/* Only once the window has tried and failed to close itself —
|
||||
otherwise this is a button that disappears as it is read. */}
|
||||
{closeRefused && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="mt-2 rounded-md bg-netbird px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "failed" && (
|
||||
<>
|
||||
<CircleXIcon className="text-red-500" size={32} />
|
||||
<h1 className="text-lg font-medium text-nb-gray-100">
|
||||
Authorization Failed
|
||||
</h1>
|
||||
<p className="text-sm text-nb-gray-300">
|
||||
{error} You can close this window; the assistant has been told it
|
||||
was refused.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 20 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user