Compare commits

..
Author SHA1 Message Date
Eduard GertandClaude Opus 5 6845094723 Size the tab list against the header and the announcement banner
The list computed calc(100vh - 75px) on its own. The header is 65px, and with
an announcement banner open DashboardLayout shrinks the row holding
PageContainer to calc(100vh - (headerHeight + bannerHeight)) -- so the list
asked for more height than its scroll container had and drifted with the
scroll instead of holding at the top.

It now takes the same figure Navigation and DashboardLayout use. That arrives
through a CSS variable rather than an inline height, which keeps the
breakpoint in CSS: the value is always a length, so the height cannot degrade
to auto and bring back the stretch that stopped it sticking in the first
place. bannerHeight is defaulted because useAnnouncement is a bare useContext
with an empty default, and NaNpx would invalidate the calc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:44:20 +02:00
Eduard GertandClaude Opus 5 b9f278360e Take the tab list's height from CSS, not a JS media query
The sidebar's height came from useIsLg, and react-responsive reports false
during SSR and the first client render. Until the query resolved the list fell
back to height auto, and as a flex item under the default align-items: stretch
that means it grows to the full content height -- so a sticky element as tall
as its own containing block, with nowhere to stick. The sidebar scrolled away
with the content on every load until the query landed, which made it look
intermittent.

Reproduced outside the app: with a fixed height the list holds at the top of
the scroll container through a scroll of 800px; with height auto it measures
the full 1500px content height and its offset tracks the scroll exactly.

h-auto lg:h-[calc(100vh_-_75px)] is correct from the first paint and drops the
hook from the component.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:30:39 +02:00
Eduard GertandClaude Opus 5 6d3928d7c0 Move the tab strip's padding inside its scroll area, and restore the fade
The padding sat on Tabs.List, outside the ScrollArea, so the Root's box ended
flush with the items and the scrollbar -- absolute, bottom-0, 10px tall -- had
nowhere to sit but on top of them. It now lives on the inner flex row, with
pb-3 clearing the track, which also means the leading gap travels with the
content instead of framing it.

The fade is back alongside the scrollbar rather than in place of it, 64px wide
and stopping short of the track. Its observers hang off the Radix viewport,
which is the element that actually scrolls; on Tabs.List they would have
measured nothing once ScrollArea went in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:25:51 +02:00
Eduard GertandClaude Opus 5 35fe6b6cbb Give the tab strip a real scrollbar instead of a hidden one
Drops no-scrollbar and the hand-rolled edge fades in favour of ScrollArea,
which is what the rest of the app uses for this -- Tabs.tsx already wraps its
horizontal list the same way. That removes the ResizeObserver, the
MutationObserver and the scroll listener that existed only to stand in for a
scrollbar nobody could see.

The horizontal strip also gets a 1px gap between items.

Root and Viewport are display:contents on lg, so on desktop they leave the
layout entirely and the tab column sits directly in the list, keeping the
sticky sidebar and its calc height untouched. Doing it in CSS rather than
branching on useIsLg matters: react-responsive reports false during SSR, so a
structural branch would render the mobile strip on desktop first and then swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:13:14 +02:00
Eduard GertandClaude Opus 5 dcfa2398e3 Fade the edges of the tab strip when it scrolls out of view
Below lg the tab list is a horizontal strip carrying no-scrollbar, so there
was nothing to say the tabs continue past the edge. A fade now sits at
whichever end still has tabs behind it, and neither is shown when everything
fits.

The state comes from the element's own scroll metrics. A ResizeObserver covers
the initial measurement, which also keeps setState out of the effect body and
so out of the way of react-hooks/set-state-in-effect. A MutationObserver
covers a permission-gated trigger appearing later, which changes scrollWidth
without resizing the element. The setter returns the previous object when
nothing moved, so a measurement caused by our own render cannot loop.

The fades need a positioned parent, which the list cannot be while it is
scrolling its own content, so the wrapper is lg:contents -- on desktop it
leaves the layout entirely and the list stays the flex item it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:08:46 +02:00
Eduard GertandClaude Opus 5 1cc2353957 Point Learn more at manage/team/sign-in-domains
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:05:21 +02:00
Eduard GertandClaude Opus 5 7189a9913d Point Learn more at the page's new location
The docs page moved under Single Sign-On.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 09:57:40 +02:00
Eduard Gert 6a37af081b Fix padding 2026-09-11 09:52:43 +02:00
Eduard GertandClaude Opus 5 4d21ce4680 Keep the vertical tab list in place while the content scrolls
PageContainer is the scroll container, so the tab list scrolled away with the
tab content: on a long Settings tab the list was gone by the time you reached
the bottom. It is now pinned to the top of that container on lg and up.

Pinning alone would trade one problem for another, because the list carries
overflow-y-hidden and a fixed height: a list taller than the viewport was
previously reachable by scrolling the page, and pinned it would just be
clipped. It gets lg:overflow-y-auto so it scrolls on its own when it has to.

Mobile is untouched, where the list is a horizontal row rather than a sidebar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:30:43 +02:00
Eduard GertandClaude Opus 5 9d8e4dc50d Move Sign-in Domains into the Authentication tab
It was a settings tab of its own, which gave a small table its own page and
its own breadcrumbs. It is now a section of the Authentication tab, below MFA,
behind a separator like MFA's own: label, description, and an Add button on
the right.

The description reads "Users from these domains can join your account." and
Learn more now points at the page that documents it rather than at the docs
root.

SignInDomainsTab.tsx is gone with the tab, and the visibility rule it held --
cloud, a private account domain, settings read -- moves to
useCanViewSignInDomains so the Authentication tab can gate the separator on
the same condition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:24:42 +02:00
Eduard GertandClaude Opus 5 26edbb8add Poll sign-in domains only while one is pending
The list polled every 15s for as long as the tab was open, even when every
domain was verified and nothing could change. It now polls only while a domain
is still pending its DNS challenge, which is the only state that moves on its
own.

RequestOptions.refreshInterval takes SWR's function form so the decision can
read the current data. The function is memoised because SWR's polling effect
keys on it, and a fresh closure each render would tear the timer down and
re-arm it continuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:11:50 +02:00
Eduard GertandClaude Opus 5 6791fb44a4 Say a domain is used by an SSO integration, not which connection
The tooltip named the connection, but that name is a generated slug like
con_7MhvKP27KnKDJbXS, so it told the customer nothing about where to go and
put an internal identifier in front of them. It now points at the integration,
matching the wording the service returns for the same refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 12:43:45 +02:00
Eduard GertandClaude Opus 5 00a1fd8113 Refine the Sign-in Domains tab and point it at /service/sign-in-domains
Follows the routes the auth service now exposes, and reworks the tab to
match the other settings tabs.

- Adding a domain happens in a modal, built like the rename-group modal,
  instead of an inline field above the table. The Add button sits in the tab
  header where the other tabs keep Save Changes.
- The tab decides whether to show itself from the login token's domain
  category rather than from GET /accounts, so its trigger paints with the
  rest of the tab list instead of appearing a moment later and pushing the
  list down. useDomainCategory now decodes the token synchronously, since
  useJwt decodes in an effect and so reports no claims on first render.
- The table's loading state is one placeholder row rendered through the real
  table, so the header, row height and padding are identical to a loaded
  table and nothing shifts when the first domain arrives.
- An unreachable service dims the panel and disables its controls, the same
  treatment the MFA panel uses, rather than saying anything about the service
  behind it. The skeleton covers the whole request, so the empty state is
  only shown when the account really has no domains.
- The delete button is icon-only and always present: the primary domain gets
  it disabled with a tooltip saying why, rather than no control at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:07:08 +02:00
Eduard Gert 08bb65dfe8 Merge remote-tracking branch 'origin/main' into feature/sign-in-domains 2026-09-10 09:27:12 +02:00
Eduard Gert f24775fe25 Add Sign-in Domains settings tab
Adds a Sign-in Domains tab to Settings, backed by the auth service's
account domain endpoints (GET/POST /service/account/domains, POST
/domains/:id/verify, DELETE /domains/:id).

The tab lists the account's domains with their verification status, and
lets an admin add one, verify ownership through the existing DNS TXT
record modal, and remove one. Adding a domain opens the verification
modal straight away unless the service returns it already verified,
which it does when the domain matches the caller's own email address.
The list polls while it is on screen, since verification finishes
server-side seconds to minutes after it starts and nothing pushes the
new status.

Remove mirrors the two cases the service refuses: the account's own
primary domain has no Remove button at all, and a domain still matched
by an SSO connection has it disabled with the connections named. When
the list cannot be loaded the section is disabled rather than showing an
empty state, so an admin never adds against a list they cannot see.

The tab is cloud-only and limited to accounts on a private email domain,
matching where the auth service adopts the account domain.

Supporting changes:

- DomainVerificationModal took a connectionId and resolved the verify
  call itself, which only worked for connection domains. It now takes an
  onVerify callback; the four existing call sites pass the same
  connection-scoped verify as before.
- DomainValidationStatus, SignInDomain and DomainConnection live in
  Account.ts, since sign-in domains belong to the account rather than to
  an identity provider.
- NoResults gained an optional hideIcon prop.
- Settings tab headings that sit in a flex row next to an action button
  rendered 4px lower than the ones that do not: flex items do not
  margin-collapse, so the h1's my-1 no longer folds into the
  breadcrumb's mb-6. Offset it so every tab heading lines up.
- Shorter one-line descriptions for Authentication, Notifications and
  Metrics.
2026-09-02 17:19:09 +02:00
68 changed files with 1321 additions and 1841 deletions

No files matched your search

+1 -2
View File
@@ -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",
"announcement": "$NETBIRD_ANNOUNCEMENT"
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
}
+1 -3
View File
@@ -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}
# 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}"
@@ -165,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_ANNOUNCEMENT"
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"
-10
View File
@@ -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: [
+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.getByTestId("pending-approval");
const approvalPending = page.getByText("User Approval Pending");
const onboarding = page.getByText("Add new device to your network");
const selectAccount = page.getByText("Select account");
const loginInput = page.locator("input[id=loginName]");
@@ -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();
}
});
});
+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.getByTestId("pending-approval");
const approval = page.getByText("User Approval Pending");
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.getByTestId("pending-approval")).toBeVisible();
await expect(page.getByText("User Approval Pending")).toBeVisible();
await context.close();
});
+397 -518
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.4",
"next": "16.3.0",
"punycode": "^2.3.1",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.0",
+20 -5
View File
@@ -61,7 +61,10 @@ 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>
@@ -78,19 +81,31 @@ 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>
+6 -15
View File
@@ -8,7 +8,6 @@ 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();
@@ -58,20 +57,9 @@ 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";
};
@@ -79,6 +67,9 @@ 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.";
};
@@ -107,7 +98,7 @@ export default function ErrorPage() {
</Paragraph>
<div className="mt-5 space-y-3">
{!isBlockedUser && (
{!isBlockedUser && !isPendingApproval && (
<Button variant="default-outline" size="sm" onClick={handleRetry}>
<RefreshCw size={16} className="mr-2" />
Try Again
@@ -115,7 +106,7 @@ export default function ErrorPage() {
)}
<Button variant="primary" size="sm" onClick={handleLogout}>
{isBlockedUser ? "Sign Out" : "Logout"}
{isBlockedUser || isPendingApproval ? "Sign Out" : "Logout"}
<ArrowRightIcon size={16} />
</Button>
</div>
+5 -3
View File
@@ -1,7 +1,7 @@
import { useOidcAccessToken, useOidcIdToken } from "@axa-fr/react-oidc";
import loadConfig from "@utils/config";
import { useMemo } from "react";
import { useJwt } from "react-jwt";
import { decodeToken } from "react-jwt";
const config = loadConfig();
export const useDomainCategory = () => {
@@ -9,10 +9,12 @@ 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"))
@@ -22,7 +24,7 @@ export const useDomainCategory = () => {
} catch (e) {
return undefined;
}
}, [decodedToken]);
}, [token]);
const isPrivate = useMemo(() => {
return domainCategory === "private";
+15 -20
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 useUrlTab from "@/hooks/useUrlTab";
import Paragraph from "@components/Paragraph";
import { NotificationChannelListItem } from "@/cloud/notifications/NotificationChannelListItem";
import NotificationProvider, {
useNotifications,
} from "@/cloud/notifications/NotificationProvider";
import { NotificationChannelListItem } from "@/cloud/notifications/NotificationChannelListItem";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useIsLicensed } from "@/hooks/useIsLicensed";
import useUrlTab from "@/hooks/useUrlTab";
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,23 +52,18 @@ const NotificationsOverview = ({
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<div className={"flex items-start justify-between -mt-1"}>
<div>
<h1>Notifications</h1>
<Paragraph>
Choose how to be notified when important events occur in your
account.
</Paragraph>
<Paragraph>
Learn more about{" "}
<Paragraph className={"block"}>
Get notified when important events happen.{" "}
<InlineLink
href={NOTIFICATION_CHANNELS_DOCS_LINK}
target={"_blank"}
>
Notification Channels
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
</div>
@@ -0,0 +1,140 @@
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>
</>
);
};
@@ -0,0 +1,72 @@
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>
);
};
@@ -0,0 +1,121 @@
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>
);
}
@@ -0,0 +1,46 @@
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";
}
@@ -0,0 +1,110 @@
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"}
/>
);
}
@@ -0,0 +1,65 @@
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;
};
+1 -8
View File
@@ -9,9 +9,6 @@ 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(
@@ -34,13 +31,9 @@ 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)}
data-testid={dataTestId}
>
<div className={cn(calloutVariants({ variant }), className)}>
{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