Compare commits

..
Author SHA1 Message Date
Eduard Gert d4451274c7 Replace the pending-approval error page with a dedicated screen (#802)
* Replace the pending-approval error page with a dedicated screen

A user waiting for an administrator to approve their account was shown
the generic /error page: an error icon, a raw response_message dump and
"if you believe this is an error, please contact your administrator".
Waiting for approval is an expected part of signing up, so it now gets a
screen of its own, following the onboarding layout of logo, centered card
and a stepper placing the user between "Account Created" and
"Join Account".

The owner to contact is read out of the refusal management sends, which
names a masked address, and the copy asks for the owner without naming
one when the address is absent.

Steps.Step gains an optional status and size, so a step can render as
complete, current or upcoming at a larger scale. Steps that pass neither
keep the look used by the instruction-list steppers.

* Detect the pending-approval screen by test id in e2e

The three places that recognise a pending user matched on "User Approval
Pending", the heading the old error page rendered. The screen that
replaced it has no heading, so the approval branch of the login races
could never resolve and every leg timed out instead: both fixtures treat
approval as a legitimate outcome, not a failure.

They now key off the data-testid on the screen's root, which does not
move when the copy does.

* Point the pending-approval footer at the docs and support pages

A mailbox asks the reader to compose something; the approval docs explain
what they are waiting for, and the support page routes them the way every
other NetBird surface does. The support address constant went with it.

* Render the pending-approval screen from the caller-identity response

Every call a pending user makes is refused, but only /users/current
names the owner who can approve them. Redirecting to /error on whichever
403 landed first therefore threw that name away about as often as it
kept it, and which screen appeared came down to a race.

Pending users are no longer routed to /error at all. The provider that
already waits on /users/current renders the screen from that response,
which also ends the wait it could never finish: the caller identity it
blocks on is exactly what such a user is refused.

* Decide the refused-user screen from the responses, not the first 403

Routing on whichever refused call landed first meant the screen depended
on a race, and once only /users/current could name the owner it meant
the name was usually thrown away. Management older than that reports a
pending user as merely blocked there, so it also meant a pending user
landed on the blocked screen, which is what the e2e run caught.

UserProfileProvider now decides from the responses it already waits on:
/users/current first, since it alone names the owner, then the user list,
which reports pending on every management version. Blocked keeps the
error page, decided after pending so the older wording cannot win.

* Wait for both refusal responses before choosing a screen

Concluding blocked the moment /users/current said so meant concluding it
while the user list was still in flight — and on management that reports
a pending user as merely blocked there, that list is the one response
that would have said otherwise. The screen it was meant to prevent was
reachable again, just through a narrower window.

The decision moves into resolveRefusedUser, which holds off while a
better-informed response is still coming: the named one before the list,
the list before blocked. Navigating to the error page becomes an effect
rather than a call made while rendering.

* Move the refused-user decision next to the provider that makes it

It sat in utils only because two callers needed it, but the second one —
the guard keeping these errors off the error boundary — needs nothing
more than to recognise them, which is a line of its own. So the decision
moves beside the provider that acts on it, and reads an error into a
RefusalReason rather than assembling a discriminated union.

The cost is that both files now spell out management's wording. The one
here is the tested reading; the one in @utils/api only has to be no
narrower than it.

* Assert the refusal kind the resolver actually returns

The result property is kind, not reason. toEqual types its argument
loosely, so the stale expectations type-checked and only failed when run.
2026-09-21 10:33:43 +02:00
Brad Ison b2e658a2be Add a deployment announcement banner set through NETBIRD_ANNOUNCEMENT (#806)
An operator can now give a deployment a permanent banner: the text in
NETBIRD_ANNOUNCEMENT is shown to every user in the prominent
announcement style, cannot be closed, and appears in every edition.  It
sits ahead of the remote and MSP announcements, behind only the billing
warnings when those open, so it is always the first thing a user sees.
Empty or unset means no banner, which is the default.
2026-09-18 10:54:22 +02:00
Jack Carter 4b2c394181 fix: keep tables from scrolling horizontally when the Active toggle shows (#805)
* fix: keep tables from scrolling horizontally when the Active toggle shows

The Active toggle column restored in #803 adds about 100px to every table,
which pushed policies and legacy routes into a horizontal scrollbar next to
an expanded sidebar on 1280px and 1440px screens.

Hide the column below the xl breakpoint (2xl for legacy routes, the widest
table) instead of dropping it. Enable/Disable remains available in each
row's action menu on narrower viewports.

Also on the legacy routes page: tighten cell padding in the expanded route
rows, remove a 10px max-width on the network address that made it overflow
into the Type column, let the Name cell shrink from 270px to 200px when
space is tight, and drop the unused sort control from the Active header.

* refactor: hide the Active column with responsive classes instead of a media query hook

Columns can now carry a className through TanStack's column meta, which
DataTable applies to both the header and body cells. The Active column
uses `hidden xl:table-cell` (`2xl` for legacy routes) so the browser
handles the breakpoint and no JavaScript listener is needed.

* refactor: apply the Active column treatment consistently and cover it with a test

- Peer routes, group resources and reverse proxy targets carry the same
  Active toggle column and now hide it below xl like the other tables.
- DataTable gains tableHeadClassName so RouteTable can tighten header and
  body padding through props instead of descendant selectors.
- The four tables reference ENABLED_COLUMN_ID instead of a literal id so
  the fade-on-disable helper and the column definitions stay linked.
- The network address cell truncates past 16rem with a title tooltip so a
  long IPv6 CIDR cannot push the routes table back into a scrollbar.
- Add a DataTable render test for column meta classes on header and body.
2026-09-17 11:08:58 +02:00
Jack Carter 74eae99820 Fix group and resource order in resource group audit events (#804) 2026-09-17 09:54:40 +02:00
Maycon SantosandClaude Fable 5.1 da81cb7f50 Pick a private-capable proxy cluster when bootstrapping the agent network (#780)
* Pick a private-capable proxy cluster when bootstrapping the agent network

The connect-provider wizard bootstraps the account's agent network
endpoint from a proxy cluster it auto-picks off /reverse-proxies/domains,
taking the first validated free domain. That list includes clusters that
cannot host the endpoint: the endpoint is a private service, reachable
only from connected peers and authorised by their tunnel identity, which
needs a cluster with at least one connected embedded proxy — the same
supports_private flag the Reverse Proxy modal gates NetBird-Only Access
on. Management refuses a bootstrap onto a cluster reporting it false, so
the wizard could propose a pick the API rejects, and the endpoint it
assigns is immutable.

Filter the candidates on supports_private before auto-picking, and give
the "nothing to pick" callout a second message for the case where
clusters exist but none of them has an embedded proxy — a different
problem from having no proxy at all, and a different fix.

Only an explicit false disqualifies a cluster: a management build that
predates the flag reports nothing at all, and dropping every cluster
there would block setup on a backend that would have accepted it.

Callout takes an optional data-testid so a spec can assert on the state
it announces rather than on its copy.

* Derive the bootstrap cluster instead of holding it in state

Nothing picks the bootstrap cluster: there is no selector, so the state
could only ever mirror the first usable entry of the domains list, with
an effect writing it back on arrival and a render pass to show it. Derive
it from that list instead, which drops the state, the effect and the two
resets that kept them in sync.

* Declare the e2e proxy private so a bootstrap cluster exists

The connect-provider wizard now offers only clusters whose proxies report
the private capability, because the agent network endpoint is a private
service and management refuses to bootstrap it anywhere else. The e2e
environment's reverse-proxy ran with NB_PROXY_PRIVATE at its default of
false, so with that filter in place no cluster qualified: the wizard
showed the no-cluster callout, Continue stayed disabled, and every spec
that walks through the wizard failed on the Models tab it could no longer
reach — agentgateway, Kimi and the refused-save spec.

Declare the capability on the primary proxy. The no-ports proxy stays
centralised, so the pick also has to skip past a cluster that does not
qualify, which is the case the wizard filter exists for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6

* Send the no-cluster callout to the agent network Clusters section

The callout told users to connect a proxy under Reverse Proxy, the generic
services page. Agent Network has its own Clusters tab under Configuration,
which is where a user setting up the agent network expects to manage the
clusters it runs on, so both messages link there instead. The tab and the
callout gate on the same permission (services read), so every role that
sees the callout can follow the link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6

* Say private capabilities, not embedded proxy, in the no-cluster callout

The requirement is a cluster with private capabilities, which is what the
supports_private flag reports. Tying the message to an embedded proxy named
one way of getting there as if it were the requirement itself, and pointed
users at the wrong fix. The callout now states the requirement and where to
connect such a cluster.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 22:19:37 +02:00
Jack Carter f4e80d37c4 feat: restore the Active toggle and keep it visible on disabled rows (#803)
Disabled routing peers, network resources, policies and legacy routes were
only distinguishable by a faded row, and the fade also dimmed the controls
needed to re-enable them. Bring back the Active toggle column that was
hidden in #654, and add a cellClassName hook to DataTable with a shared
fadeDisabledRowCells helper that fades every cell of a disabled row except
the toggle and actions cells.
2026-09-14 15:43:50 +02:00
Pascal Fischer de045582e5 mark is cloud false unless explicitly overridden (#799) 2026-09-12 09:07:45 +02:00
Eduard GertandClaude Opus 5 6a85748b91 chore: update Next.js to 16.3.4 and refresh dependencies (#797)
Bump next from 16.3.0 to 16.3.4 and refresh the transitive lockfile
entries that came with it (@next/* swc binaries, sharp 0.35.4,
rolldown, vitest, playwright).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 14:47:14 +02:00
68 changed files with 1841 additions and 1321 deletions

No files matched your search

+2 -1
View File
@@ -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"
}
+3 -1
View File
@@ -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"
+10
View File
@@ -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
View File
@@ -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();
}
});
});
+1 -1
View File
@@ -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();
});
+518 -397
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -75,7 +75,7 @@
"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",
+5 -20
View File
@@ -61,10 +61,7 @@ export default function NetBirdSettings() {
<VerticalTabs.List>
{permission.settings.read && (
<>
<VerticalTabs.Trigger
value="authentication"
data-testid="settings-tab-authentication"
>
<VerticalTabs.Trigger value="authentication" data-testid="settings-tab-authentication">
<ShieldIcon size={14} />
Authentication
</VerticalTabs.Trigger>
@@ -81,31 +78,19 @@ export default function NetBirdSettings() {
Identity Providers
</VerticalTabs.Trigger>
)}
<VerticalTabs.Trigger
value="groups"
data-testid="settings-tab-groups"
>
<VerticalTabs.Trigger value="groups" data-testid="settings-tab-groups">
<FolderGit2Icon size={14} />
Groups
</VerticalTabs.Trigger>
<VerticalTabs.Trigger
value="permissions"
data-testid="settings-tab-permissions"
>
<VerticalTabs.Trigger value="permissions" data-testid="settings-tab-permissions">
<LockIcon size={14} />
Permissions
</VerticalTabs.Trigger>
<VerticalTabs.Trigger
value="networks"
data-testid="settings-tab-networks"
>
<VerticalTabs.Trigger value="networks" data-testid="settings-tab-networks">
<NetworkIcon size={14} />
Networks
</VerticalTabs.Trigger>
<VerticalTabs.Trigger
value="clients"
data-testid="settings-tab-clients"
>
<VerticalTabs.Trigger value="clients" data-testid="settings-tab-clients">
<MonitorSmartphoneIcon size={14} />
Clients
</VerticalTabs.Trigger>
+15 -6
View File
@@ -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>
+3 -5
View File
@@ -1,7 +1,7 @@
import { useOidcAccessToken, useOidcIdToken } from "@axa-fr/react-oidc";
import loadConfig from "@utils/config";
import { useMemo } from "react";
import { decodeToken } from "react-jwt";
import { useJwt } from "react-jwt";
const config = loadConfig();
export const useDomainCategory = () => {
@@ -9,12 +9,10 @@ export const useDomainCategory = () => {
const { idToken } = useOidcIdToken();
const { accessToken } = useOidcAccessToken();
const token = tokenSource.toLowerCase() == "idtoken" ? idToken : accessToken;
const { decodedToken } = useJwt<Record<any, any>>(token);
const domainCategory = useMemo(() => {
try {
const decodedToken = token
? decodeToken<Record<any, any>>(token)
: undefined;
const key = decodedToken
? Object.keys(decodedToken)
.filter((key) => key.includes("wt_account_domain_category"))
@@ -24,7 +22,7 @@ export const useDomainCategory = () => {
} catch (e) {
return undefined;
}
}, [token]);
}, [decodedToken]);
const isPrivate = useMemo(() => {
return domainCategory === "private";
+20 -15
View File
@@ -1,28 +1,28 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Card from "@components/Card";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { SkeletonNotificationSettings } from "@components/skeletons/SkeletonNotificationSettings";
import { SmallBadge } from "@components/ui/SmallBadge";
import { VerticalTabs } from "@components/VerticalTabs";
import * as Tabs from "@radix-ui/react-tabs";
import { ExternalLinkIcon, MessageSquareDot } from "lucide-react";
import React from "react";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useIsLicensed } from "@/hooks/useIsLicensed";
import { VerticalTabs } from "@components/VerticalTabs";
import InlineLink from "@components/InlineLink";
import Card from "@components/Card";
import { NotificationEmailChannel } from "@/cloud/notifications/channels/NotificationEmailChannel";
import { NotificationSlackChannel } from "@/cloud/notifications/channels/NotificationSlackChannel";
import { NotificationWebhookChannel } from "@/cloud/notifications/channels/NotificationWebhookChannel";
import { NotificationChannelListItem } from "@/cloud/notifications/NotificationChannelListItem";
import useUrlTab from "@/hooks/useUrlTab";
import Paragraph from "@components/Paragraph";
import NotificationProvider, {
useNotifications,
} from "@/cloud/notifications/NotificationProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useIsLicensed } from "@/hooks/useIsLicensed";
import useUrlTab from "@/hooks/useUrlTab";
import { NotificationChannelListItem } from "@/cloud/notifications/NotificationChannelListItem";
import {
NOTIFICATION_CHANNELS_DOCS_LINK,
NotificationChannelType,
} from "@/interfaces/NotificationChannel";
import { SkeletonNotificationSettings } from "@components/skeletons/SkeletonNotificationSettings";
import { NotificationSlackChannel } from "@/cloud/notifications/channels/NotificationSlackChannel";
import { SmallBadge } from "@components/ui/SmallBadge";
const NotificationsOverview = ({
onSelectChannel,
@@ -52,18 +52,23 @@ const NotificationsOverview = ({
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between -mt-1"}>
<div className={"flex items-start justify-between"}>
<div>
<h1>Notifications</h1>
<Paragraph className={"block"}>
Get notified when important events happen.{" "}
<Paragraph>
Choose how to be notified when important events occur in your
account.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink
href={NOTIFICATION_CHANNELS_DOCS_LINK}
target={"_blank"}
>
Learn more
Notification Channels
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
</div>
@@ -1,140 +0,0 @@
import Button from "@components/Button";
import { Input } from "@components/Input";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import { validator } from "@utils/helpers";
import { PlusCircle } from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useSignInDomains } from "@/cloud/sign-in-domains/useSignInDomains";
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
import { DomainVerificationModal } from "@/modules/integrations/sso/DomainVerificationModal";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export const AddSignInDomainModal = ({ open, onOpenChange }: Props) => {
const { addDomain, verifyDomain, mutate } = useSignInDomains();
const [name, setName] = useState("");
const [verifyModal, setVerifyModal] = useState(false);
const [addedDomain, setAddedDomain] = useState<SignInDomain>();
const validationError = useMemo(() => {
const domain = name.trim();
if (domain === "") return "";
const valid = validator.isValidDomain(domain, {
allowWildcard: false,
allowOnlyTld: false,
preventLeadingAndTrailingDots: true,
});
if (!valid) return "Please enter a valid domain, e.g. company.com";
return "";
}, [name]);
const setOpen = (next: boolean) => {
if (!next) setName("");
onOpenChange(next);
};
const submit = () => {
const domain = name.trim();
if (!domain || validationError) return;
setOpen(false);
notify({
title: "Sign-in Domains",
description: `${domain} has been added`,
promise: addDomain(domain).then((added) => {
if (
added &&
added.validation_status !== DomainValidationStatus.VERIFIED
) {
setAddedDomain(added);
setVerifyModal(true);
}
mutate().catch(() => {});
return added;
}),
loadingMessage: "Adding domain...",
});
};
return (
<>
{addedDomain && (
<DomainVerificationModal
open={verifyModal}
onOpenChange={setVerifyModal}
domain={addedDomain.name}
token={addedDomain.validation_token}
onVerify={() =>
verifyDomain(addedDomain.id).then((res) => {
mutate().catch(() => {});
return res;
})
}
/>
)}
<Modal open={open} onOpenChange={setOpen}>
<ModalContent maxWidthClass={"max-w-md"}>
<ModalHeader
className={"pb-4 px-8"}
title={"Add Sign-in Domain"}
description={"Users on this domain are matched to your account."}
/>
<form
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<div className={"p-default flex flex-col gap-4"}>
<div>
<Input
autoFocus
data-testid={"add-domain-input"}
placeholder={"e.g. company.com"}
value={name}
error={validationError}
onChange={(event) => setName(event.target.value)}
/>
</div>
</div>
<ModalFooter className={"items-center"} separator={false}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
type={"submit"}
disabled={!name.trim() || !!validationError}
data-testid={"add-domain-submit"}
>
<PlusCircle size={16} />
Add
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
</Modal>
</>
);
};
@@ -1,72 +0,0 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Label } from "@components/Label";
import { cn } from "@utils/helpers";
import { isNetBirdCloud } from "@utils/netbird";
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { useDomainCategory } from "@/cloud/cloud-hooks/useDomainCategory";
import { AddSignInDomainModal } from "@/cloud/sign-in-domains/AddSignInDomainModal";
import SignInDomainsTable from "@/cloud/sign-in-domains/table/SignInDomainsTable";
import { useSignInDomains } from "@/cloud/sign-in-domains/useSignInDomains";
import { usePermissions } from "@/contexts/PermissionsProvider";
const POLL_INTERVAL_MS = 15_000;
const SIGN_IN_DOMAINS_DOCS_LINK =
"https://docs.netbird.io/manage/team/sign-in-domains";
export const useCanViewSignInDomains = () => {
const { permission } = usePermissions();
const { isPrivate } = useDomainCategory();
return Boolean(permission?.settings?.read) && isNetBirdCloud() && isPrivate;
};
export const SignInDomainsSettings = () => {
const { permission } = usePermissions();
const { domains, isLoading, isUnavailable } =
useSignInDomains(POLL_INTERVAL_MS);
const [addModal, setAddModal] = useState(false);
const showSkeleton = isLoading && !isUnavailable;
return (
<div
className={cn(
isLoading && "animate-pulse pointer-events-none opacity-90",
isUnavailable && "pointer-events-none opacity-70",
)}
data-testid={"sign-in-domains"}
>
<div className={"flex items-end justify-between gap-4 mb-4"}>
<div className={"min-w-0"}>
<Label>Sign-in Domains</Label>
<HelpText className={"!mb-0"}>
Users from these domains can join your account.{" "}
<InlineLink href={SIGN_IN_DOMAINS_DOCS_LINK} target={"_blank"}>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</HelpText>
</div>
<Button
variant={"primary"}
size={"xs"}
className={"shrink-0"}
disabled={!permission?.settings?.update || isUnavailable}
onClick={() => setAddModal(true)}
data-testid={"add-domain"}
>
<PlusCircle size={16} />
Add
</Button>
</div>
<SignInDomainsTable domains={domains} isLoading={showSkeleton} />
<AddSignInDomainModal open={addModal} onOpenChange={setAddModal} />
</div>
);
};
@@ -1,121 +0,0 @@
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { notify } from "@components/Notification";
import { cn } from "@utils/helpers";
import { TrashIcon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { useSignInDomains } from "@/cloud/sign-in-domains/useSignInDomains";
import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
import { DomainVerificationModal } from "@/modules/integrations/sso/DomainVerificationModal";
type Props = {
domain: SignInDomain;
};
export default function DomainActionCell({ domain }: Readonly<Props>) {
const { permission } = usePermissions();
const { verifyDomain, deleteDomain, mutate } = useSignInDomains();
const { confirm } = useDialog();
const [modal, setModal] = useState(false);
const isVerified =
domain.validation_status === DomainValidationStatus.VERIFIED;
const connections = domain.connections ?? [];
const removeBlockedReason: React.ReactNode = domain.is_primary ? (
<>
This is the primary domain of your account
<br />
and cannot be removed.
</>
) : connections.length > 0 ? (
<>
This domain is used by an SSO integration.
<br />
Remove it there before deleting it.
</>
) : undefined;
const deleteDomainHandler = async () => {
const choice = await confirm({
title: `Remove ${domain.name}?`,
description:
"Users with an email address on this domain will no longer be matched to your account.",
confirmText: "Remove",
cancelText: "Cancel",
type: "danger",
maxWidthClass: "max-w-md",
});
if (!choice) return;
notify({
title: "Sign-in Domains",
description: `${domain.name} has been removed`,
promise: deleteDomain(domain.id).then((res) => {
mutate().catch(() => {});
return res;
}),
loadingMessage: "Removing domain...",
});
};
return (
<div
className={
"relative top-[2px] flex gap-2 items-center justify-end ml-auto min-h-[34px]"
}
>
<DomainVerificationModal
open={modal}
onOpenChange={setModal}
domain={domain.name}
token={domain.validation_token}
onVerify={() =>
verifyDomain(domain.id).then((res) => {
mutate().catch(() => {});
return res;
})
}
/>
{!isVerified && (
<Button
variant={"secondary"}
size={"xs"}
className={"!px-3"}
disabled={!permission.settings.update}
onClick={() => setModal(true)}
data-testid={"verify-domain"}
>
Verify
</Button>
)}
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
{removeBlockedReason ?? "Remove domain"}
</div>
}
interactive={false}
>
<Button
variant={"danger-outline"}
size={"xs"}
className={cn(
"!p-0 !h-[34px] !w-[34px]",
removeBlockedReason && "pointer-events-none",
)}
disabled={!!removeBlockedReason || !permission.settings.update}
onClick={deleteDomainHandler}
aria-label={"Remove domain"}
data-testid={"remove-domain"}
>
<TrashIcon size={14} />
</Button>
</FullTooltip>
</div>
);
}
@@ -1,46 +0,0 @@
import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers";
import * as React from "react";
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
type Props = {
domain: SignInDomain;
};
const FAILED_HINT =
"This domain could not be verified. Check that the TXT record is published, then try again. If it keeps failing, contact support@netbird.io";
export default function DomainStatusCell({ domain }: Readonly<Props>) {
const status = domain.validation_status;
const isVerified = status === DomainValidationStatus.VERIFIED;
const isPending = status === DomainValidationStatus.PENDING;
const isFailed = status === DomainValidationStatus.FAILED;
return (
<FullTooltip
content={<div className={"text-xs max-w-xs"}>{FAILED_HINT}</div>}
disabled={!isFailed}
interactive={false}
>
<div
className={"flex items-center gap-2 text-sm text-nb-gray-300 mr-auto"}
>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
isVerified && "bg-green-400",
isPending && "bg-yellow-400",
!isVerified && !isPending && "bg-red-500",
)}
/>
{statusLabel(status)}
</div>
</FullTooltip>
);
}
function statusLabel(status: DomainValidationStatus) {
if (status === DomainValidationStatus.VERIFIED) return "Verified";
if (status === DomainValidationStatus.PENDING) return "Pending";
return "Unverified";
}
@@ -1,110 +0,0 @@
import Card from "@components/Card";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import NoResults from "@components/ui/NoResults";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import * as React from "react";
import { useState } from "react";
import Skeleton from "react-loading-skeleton";
import DomainActionCell from "@/cloud/sign-in-domains/table/DomainActionCell";
import DomainStatusCell from "@/cloud/sign-in-domains/table/DomainStatusCell";
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
type Props = {
domains?: SignInDomain[];
isLoading?: boolean;
};
const PLACEHOLDER_ID = "__loading__";
const placeholderRow: SignInDomain = {
id: PLACEHOLDER_ID,
name: "",
is_primary: false,
validation_token: "",
validation_status: DomainValidationStatus.PENDING,
validation_last_updated: null,
};
const isPlaceholder = (domain: SignInDomain) => domain.id === PLACEHOLDER_ID;
const SignInDomainsColumns: ColumnDef<SignInDomain>[] = [
{
header: ({ column }) => {
return <DataTableHeader column={column}>Domain</DataTableHeader>;
},
accessorKey: "name",
sortingFn: "text",
cell: ({ row }) =>
isPlaceholder(row.original) ? (
<Skeleton height={10} width={"60%"} />
) : (
<div className={"text-sm text-nb-gray-100"}>{row.original.name}</div>
),
},
{
header: ({ column }) => {
return <DataTableHeader column={column}>Status</DataTableHeader>;
},
accessorKey: "validation_status",
sortingFn: "text",
cell: ({ row }) =>
isPlaceholder(row.original) ? (
<Skeleton height={10} width={"50%"} />
) : (
<DomainStatusCell domain={row.original} />
),
},
{
accessorKey: "id",
header: () => null,
enableSorting: false,
cell: ({ row }) =>
isPlaceholder(row.original) ? (
<div className={"flex items-center justify-end ml-auto min-h-[34px]"} />
) : (
<DomainActionCell domain={row.original} />
),
},
];
export default function SignInDomainsTable({
domains,
isLoading,
}: Readonly<Props>) {
const [sorting, setSorting] = useState<SortingState>([
{
id: "name",
desc: false,
},
]);
return (
<DataTable
wrapperComponent={Card}
wrapperProps={{ className: "w-full pb-1" }}
useRowId={true}
sorting={sorting}
setSorting={setSorting}
minimal={true}
showSearchAndFilters={false}
inset={false}
tableClassName={"mt-0"}
text={"Domains"}
columns={SignInDomainsColumns}
keepStateInLocalStorage={false}
data={isLoading ? [placeholderRow] : domains}
getStartedCard={
<NoResults
className={"py-4"}
hideIcon
title={"No sign-in domains yet"}
description={
"Add a domain, then verify ownership with a DNS record to start matching users to this account."
}
/>
}
paginationPaddingClassName={"px-0 pt-8"}
/>
);
}
@@ -1,65 +0,0 @@
import useFetchApi, { useApiCall } from "@utils/api";
import loadConfig from "@utils/config";
import { useCallback } from "react";
import { DomainValidationStatus, SignInDomain } from "@/interfaces/Account";
const config = loadConfig();
export const useSignInDomains = (pollWhilePendingMs?: number) => {
const refreshInterval = useCallback(
(latest?: SignInDomain[]) =>
pollWhilePendingMs &&
latest?.some(
(domain) => domain.validation_status === DomainValidationStatus.PENDING,
)
? pollWhilePendingMs
: 0,
[pollWhilePendingMs],
);
const {
data: domains,
isLoading,
error,
mutate,
} = useFetchApi<SignInDomain[]>(
"/service/sign-in-domains",
true,
true,
!!config.authServiceUrl,
{
origin: config.authServiceUrl,
refreshInterval,
shouldRetryOnError: false,
},
);
const request = useApiCall<SignInDomain>("/service/sign-in-domains", true, {
origin: config.authServiceUrl,
});
const addDomain = async (domain: string) => {
return request.post({ domain });
};
const verifyDomain = async (domainId: string) => {
return request.post({}, `/${domainId}/verify`);
};
const deleteDomain = async (domainId: string) => {
return request.del({}, `/${domainId}`);
};
const isUnavailable = !!error || (!isLoading && !domains);
return {
domains,
isLoading,
isUnavailable,
error,
mutate,
addDomain,
verifyDomain,
deleteDomain,
} as const;
};
+8 -1
View File
@@ -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>
Loaded 20 of 68 files, more files were not shown because too many files have changed in this diff. Show more