mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4451274c7 | ||
|
|
b2e658a2be | ||
|
|
4b2c394181 | ||
|
|
74eae99820 | ||
|
|
da81cb7f50 | ||
|
|
f4e80d37c4 | ||
|
|
de045582e5 | ||
|
|
6a85748b91 |
No files matched your search
+2
-1
@@ -24,5 +24,6 @@
|
||||
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
|
||||
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
|
||||
"hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID",
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS",
|
||||
"announcement": "$NETBIRD_ANNOUNCEMENT"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -926,6 +926,16 @@ services:
|
||||
# GeoLite2 download removes a startup stall of up to 2 minutes
|
||||
# when pkgs.netbird.io is slow.
|
||||
- NB_PROXY_DISABLE_GEOLOCATION=true
|
||||
# Declare the private capability so this cluster can host the agent
|
||||
# network endpoint. That endpoint is a private service — reachable only
|
||||
# from connected peers — and management refuses to bootstrap it onto a
|
||||
# cluster without a private-capable proxy; the connect-provider wizard
|
||||
# reads the same flag (supports_private) to pick its cluster. Without
|
||||
# it no cluster qualifies and the wizard stops at the provider tab,
|
||||
# which strands every spec that walks through it. The no-ports proxy
|
||||
# below stays centralised, so the pick has to skip past a cluster that
|
||||
# does not qualify.
|
||||
- NB_PROXY_PRIVATE=true
|
||||
volumes:
|
||||
- ./proxy-certs:/certs:ro
|
||||
command: [
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export async function loginToApp(
|
||||
// Use locators that match either outcome — Playwright auto-waits.
|
||||
const appReady = page.getByTestId("left-navigation-item").first();
|
||||
const setupModal = page.getByTestId("setup-netbird-modal");
|
||||
const approvalPending = page.getByText("User Approval Pending");
|
||||
const approvalPending = page.getByTestId("pending-approval");
|
||||
const onboarding = page.getByText("Add new device to your network");
|
||||
const selectAccount = page.getByText("Select account");
|
||||
const loginInput = page.locator("input[id=loginName]");
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Agent Network bootstrap-cluster spec.
|
||||
*
|
||||
* The account's agent network endpoint is assigned once, from the proxy
|
||||
* cluster the connect-provider wizard pins it to, and it is immutable after
|
||||
* that. Not every live cluster can host it: the endpoint is a private service,
|
||||
* reachable only from connected peers and authorised by their tunnel identity,
|
||||
* which only a cluster with private capabilities can serve. Management
|
||||
* reports that per cluster as supports_private and refuses a
|
||||
* bootstrap onto a cluster without it.
|
||||
*
|
||||
* This spec pins the wizard's half of that contract: it picks a
|
||||
* private-capable cluster even when a centralised one is listed first, and it
|
||||
* blocks setup — with a message naming what is missing — when no listed
|
||||
* cluster qualifies. Both the settings and domains routes are mocked, so the
|
||||
* assertions do not depend on which clusters the test backend happens to have
|
||||
* connected, and no provider is created against it.
|
||||
*/
|
||||
import { type Browser, expect, type Page, test } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
// Listed first, and unusable: a centralised proxy cluster. Picking it is the
|
||||
// bug this spec guards against.
|
||||
const CENTRAL_CLUSTER = "central.proxy.netbird.io";
|
||||
// Listed second, and the only valid pick.
|
||||
const PRIVATE_CLUSTER = "private.proxy.netbird.io";
|
||||
|
||||
const UNBOOTSTRAPPED_DEFAULTS = {
|
||||
endpoint: "",
|
||||
proxy_address: "",
|
||||
dedicated: false,
|
||||
enable_log_collection: true,
|
||||
enable_prompt_collection: false,
|
||||
redact_pii: false,
|
||||
access_log_retention_days: 30,
|
||||
};
|
||||
|
||||
const BOOTSTRAPPED_SETTINGS = {
|
||||
...UNBOOTSTRAPPED_DEFAULTS,
|
||||
endpoint: `violet.${PRIVATE_CLUSTER}`,
|
||||
proxy_address: PRIVATE_CLUSTER,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
/** A free (cluster-backed) domain as /reverse-proxies/domains reports it. */
|
||||
function freeDomain(name: string, supportsPrivate: boolean) {
|
||||
return {
|
||||
id: name,
|
||||
domain: name,
|
||||
validated: true,
|
||||
type: "free",
|
||||
supports_private: supportsPrivate,
|
||||
supports_custom_ports: true,
|
||||
require_subdomain: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function json(route: import("@playwright/test").Route, body: unknown) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the providers page for an account that has not been bootstrapped,
|
||||
* with the given clusters on the domains endpoint. Returns the page plus a
|
||||
* promise-friendly getter for the bootstrap POST body, which is captured and
|
||||
* answered locally so the real backend is never mutated.
|
||||
*/
|
||||
async function openWizard(
|
||||
browser: Browser,
|
||||
domains: ReturnType<typeof freeDomain>[],
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
try {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.route("**/api/agent-network/settings", (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") return json(route, UNBOOTSTRAPPED_DEFAULTS);
|
||||
if (method === "POST") return json(route, BOOTSTRAPPED_SETTINGS);
|
||||
return route.continue();
|
||||
});
|
||||
await page.route("**/api/reverse-proxies/domains", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? json(route, domains)
|
||||
: route.continue(),
|
||||
);
|
||||
// Keep the create hermetic: the wizard only reaches this once the
|
||||
// bootstrap it does first has succeeded.
|
||||
await page.route("**/api/agent-network/providers", (route) =>
|
||||
route.request().method() === "POST"
|
||||
? json(route, { id: "prov-e2e", name: "e2e-provider" })
|
||||
: route.continue(),
|
||||
);
|
||||
|
||||
await loginToApp(page, "owner");
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
return { page, close: () => context.close() };
|
||||
} catch (e) {
|
||||
await context.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fills the provider tab with the minimum a create needs. */
|
||||
async function fillProviderTab(page: Page) {
|
||||
await page
|
||||
.getByPlaceholder("https://api.openai.com")
|
||||
.fill("https://api.openai.com");
|
||||
await page.getByPlaceholder("sk-...").first().fill("sk-e2e-bootstrap-key");
|
||||
}
|
||||
|
||||
test.describe("Agent Network bootstrap cluster @agent-network", () => {
|
||||
test("bootstraps onto a private-capable cluster, skipping a centralised one listed first", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
freeDomain(PRIVATE_CLUSTER, true),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
// The unusable cluster is listed, but it is not a blocker: a usable one
|
||||
// exists, so the wizard proceeds without the warning.
|
||||
await expect(
|
||||
page.getByTestId("agent-network-no-cluster-callout"),
|
||||
).toHaveCount(0);
|
||||
|
||||
await fillProviderTab(page);
|
||||
|
||||
const bootstrap = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/agent-network/settings") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Continue" })
|
||||
.click({ force: true });
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.last()
|
||||
.click({ force: true });
|
||||
|
||||
const payload = (await bootstrap).request().postDataJSON();
|
||||
expect(payload.proxy_address).toBe(PRIVATE_CLUSTER);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks setup when no listed cluster has private capabilities", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
const callout = page.getByTestId("agent-network-no-cluster-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 10_000 });
|
||||
await expect(callout).toContainText("private capabilities");
|
||||
// The fix lives in the agent network's own Clusters section, not the
|
||||
// generic Reverse Proxy page, so that is where the callout sends users.
|
||||
await expect(callout.getByRole("link")).toHaveAttribute(
|
||||
"href",
|
||||
"/agent-network/configuration?tab=clusters",
|
||||
);
|
||||
|
||||
// With nothing to pin to there is no bootstrap to make, so the wizard
|
||||
// cannot advance — rather than sending a request management refuses.
|
||||
await fillProviderTab(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Continue" }),
|
||||
).toBeDisabled();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -102,7 +102,7 @@ async function loginAndSave(
|
||||
const skipButton = page.locator("button[name=skip]");
|
||||
const appNav = page.getByTestId("left-navigation-item").first();
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
const approval = page.getByText("User Approval Pending");
|
||||
const approval = page.getByTestId("pending-approval");
|
||||
|
||||
let after_login: "2fa" | "app" | "modal" | "approval";
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@ test.describe.serial("User Approval & Billing Admin @team", () => {
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await loginToApp(page, "user");
|
||||
await expect(page.getByText("User Approval Pending")).toBeVisible();
|
||||
await expect(page.getByTestId("pending-approval")).toBeVisible();
|
||||
await context.close();
|
||||
});
|
||||
|
||||
|
||||
Generated
+518
-397
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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",
|
||||
|
||||
+15
-6
@@ -8,6 +8,7 @@ import { ArrowRightIcon, RefreshCw } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import { PendingApproval } from "@/modules/users/PendingApproval";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
@@ -57,9 +58,20 @@ export default function ErrorPage() {
|
||||
error?.code === 403 &&
|
||||
error?.message?.toLowerCase().includes("pending approval");
|
||||
|
||||
// Waiting for an approval is an expected part of signing up, so it gets a
|
||||
// welcoming screen of its own instead of the error treatment.
|
||||
if (isPendingApproval) {
|
||||
return (
|
||||
<PendingApproval
|
||||
error={error}
|
||||
onRefresh={handleRetry}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getTitle = () => {
|
||||
if (isBlockedUser) return "User Account Blocked";
|
||||
if (isPendingApproval) return "User Approval Pending";
|
||||
return "Access Error";
|
||||
};
|
||||
|
||||
@@ -67,9 +79,6 @@ export default function ErrorPage() {
|
||||
if (isBlockedUser) {
|
||||
return "Your access has been blocked by the NetBird account administrator, possibly due to new user approval requirements or security policies. Please contact your administrator to regain access.";
|
||||
}
|
||||
if (isPendingApproval) {
|
||||
return "Your account is pending approval from an administrator. Please wait for approval before accessing the dashboard.";
|
||||
}
|
||||
return "An error occurred while trying to access the dashboard. Please try again or contact your administrator.";
|
||||
};
|
||||
|
||||
@@ -98,7 +107,7 @@ export default function ErrorPage() {
|
||||
</Paragraph>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{!isBlockedUser && !isPendingApproval && (
|
||||
{!isBlockedUser && (
|
||||
<Button variant="default-outline" size="sm" onClick={handleRetry}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Try Again
|
||||
@@ -106,7 +115,7 @@ export default function ErrorPage() {
|
||||
)}
|
||||
|
||||
<Button variant="primary" size="sm" onClick={handleLogout}>
|
||||
{isBlockedUser || isPendingApproval ? "Sign Out" : "Logout"}
|
||||
{isBlockedUser ? "Sign Out" : "Logout"}
|
||||
<ArrowRightIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,9 @@ type Props = {
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
/** Lets callers mark a callout so tests can assert on the state it
|
||||
* announces rather than on its copy. */
|
||||
"data-testid"?: string;
|
||||
} & CalloutVariants;
|
||||
|
||||
export const calloutVariants = cva(
|
||||
@@ -31,9 +34,13 @@ export const Callout = ({
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />,
|
||||
className,
|
||||
variant = "default",
|
||||
"data-testid": dataTestId,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={cn(calloutVariants({ variant }), className)}>
|
||||
<div
|
||||
className={cn(calloutVariants({ variant }), className)}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{icon}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -18,13 +18,34 @@ export default function Steps({
|
||||
);
|
||||
}
|
||||
|
||||
// Steps without a status keep the neutral look of the instruction-list
|
||||
// steppers, where no step is ever "reached".
|
||||
type StepStatus = "complete" | "current" | "upcoming";
|
||||
|
||||
// The rail has to meet the middle of the circle, so its offset is half the
|
||||
// circle and moves with the size.
|
||||
const stepSizes = {
|
||||
default: {
|
||||
circle: "h-[34px] w-[34px]",
|
||||
railHorizontal: "mt-[16px]",
|
||||
railVertical: "ml-[18px]",
|
||||
},
|
||||
large: {
|
||||
circle: "h-[44px] w-[44px]",
|
||||
railHorizontal: "mt-[21px]",
|
||||
railVertical: "ml-[23px]",
|
||||
},
|
||||
};
|
||||
|
||||
type StepProps = {
|
||||
children: React.ReactNode;
|
||||
step: number;
|
||||
step: React.ReactNode;
|
||||
line?: boolean;
|
||||
center?: boolean;
|
||||
horizontal?: boolean;
|
||||
disabled?: boolean;
|
||||
status?: StepStatus;
|
||||
size?: keyof typeof stepSizes;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -35,8 +56,12 @@ const Step = ({
|
||||
center = false,
|
||||
horizontal,
|
||||
disabled = false,
|
||||
status,
|
||||
size = "default",
|
||||
className,
|
||||
}: StepProps) => {
|
||||
const sizing = stepSizes[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -52,18 +77,31 @@ const Step = ({
|
||||
className={cn(
|
||||
"bg-nb-gray-100 dark:bg-nb-gray-800 z-0 transition-all",
|
||||
horizontal
|
||||
? "w-full h-[2px] absolute mt-[16px] transform translate-x-1/2"
|
||||
: "h-full w-[2px] absolute left-0 ml-[18px]",
|
||||
? cn(
|
||||
"w-full h-[2px] absolute transform translate-x-1/2",
|
||||
sizing.railHorizontal,
|
||||
)
|
||||
: cn("h-full w-[2px] absolute left-0", sizing.railVertical),
|
||||
// The line trails its step, so a completed step also means the hop
|
||||
// to the next one is behind us.
|
||||
status === "complete" && "bg-netbird dark:bg-netbird",
|
||||
)}
|
||||
></span>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"h-[34px] w-[34px] shrink-0 rounded-full flex items-center justify-center font-medium text-xs relative z-0 border-4 transition-all",
|
||||
"dark:bg-nb-gray-900 dark:text-nb-gray-400 dark:border-nb-gray dark:group-hover:bg-nb-gray-800",
|
||||
"bg-nb-gray-100 text-nb-gray-400 border-white group-hover:bg-nb-gray-200 step-circle",
|
||||
"shrink-0 rounded-full flex items-center justify-center font-medium text-xs relative z-0 border-4 transition-all",
|
||||
sizing.circle,
|
||||
"dark:bg-nb-gray-900 dark:text-nb-gray-400 dark:border-nb-gray",
|
||||
"bg-nb-gray-100 text-nb-gray-400 border-white step-circle",
|
||||
"[.stepper-bg-variant]:border-nb-gray-940",
|
||||
!status &&
|
||||
"group-hover:bg-nb-gray-200 dark:group-hover:bg-nb-gray-800",
|
||||
status && "border-white dark:border-nb-gray-940",
|
||||
status === "complete" &&
|
||||
"bg-netbird text-white dark:bg-netbird dark:text-white",
|
||||
status === "current" && "text-nb-gray-800 dark:text-white",
|
||||
)}
|
||||
>
|
||||
{step}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// A column's `meta.className` must land on both its header cell and its body
|
||||
// cells, otherwise a responsive class such as `hidden xl:table-cell` would hide
|
||||
// one half of the column and leave the other behind.
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/test",
|
||||
}));
|
||||
|
||||
const { DataTable } = await import("@components/table/DataTable");
|
||||
|
||||
type Row = { id: string; name: string; enabled: boolean };
|
||||
|
||||
const rows: Row[] = [
|
||||
{ id: "a", name: "First", enabled: true },
|
||||
{ id: "b", name: "Second", enabled: false },
|
||||
];
|
||||
|
||||
const columns: ColumnDef<Row>[] = [
|
||||
{ id: "name", accessorKey: "name", header: "Name" },
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: "Active",
|
||||
meta: { className: "hidden xl:table-cell" },
|
||||
cell: ({ row }) => (row.original.enabled ? "on" : "off"),
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("DataTable column meta className", () => {
|
||||
it("applies the class to the column's header cell", () => {
|
||||
const { container } = render(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
showSearchAndFilters={false}
|
||||
keepStateInLocalStorage={false}
|
||||
/>,
|
||||
);
|
||||
const headers = container.querySelectorAll("thead th");
|
||||
expect(headers[0].className).not.toContain("xl:table-cell");
|
||||
expect(headers[1].className).toContain("hidden");
|
||||
expect(headers[1].className).toContain("xl:table-cell");
|
||||
});
|
||||
|
||||
it("applies the class to every body cell of the column", () => {
|
||||
const { container } = render(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
showSearchAndFilters={false}
|
||||
keepStateInLocalStorage={false}
|
||||
/>,
|
||||
);
|
||||
const bodyRows = container.querySelectorAll("tbody tr");
|
||||
expect(bodyRows).toHaveLength(rows.length);
|
||||
bodyRows.forEach((row) => {
|
||||
const cells = row.querySelectorAll("td");
|
||||
expect(cells[0].className).not.toContain("xl:table-cell");
|
||||
expect(cells[1].className).toContain("hidden");
|
||||
expect(cells[1].className).toContain("xl:table-cell");
|
||||
});
|
||||
});
|
||||
|
||||
it("combines tableHeadClassName with the column meta class", () => {
|
||||
const { container } = render(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
showSearchAndFilters={false}
|
||||
keepStateInLocalStorage={false}
|
||||
tableHeadClassName={"px-4"}
|
||||
/>,
|
||||
);
|
||||
const headers = container.querySelectorAll("thead th");
|
||||
expect(headers[0].className).toContain("px-4");
|
||||
expect(headers[1].className).toContain("px-4");
|
||||
expect(headers[1].className).toContain("xl:table-cell");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { RankingInfo } from "@tanstack/match-sorter-utils";
|
||||
import {
|
||||
Cell,
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
Row,
|
||||
RowData,
|
||||
RowSelectionState,
|
||||
SortingFn,
|
||||
SortingState,
|
||||
@@ -55,6 +57,10 @@ declare module "@tanstack/table-core" {
|
||||
checkbox: SortingFn<unknown>;
|
||||
datetime: SortingFn<unknown>;
|
||||
}
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
/** Classes applied to both the header and body cells of the column. */
|
||||
className?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
|
||||
@@ -166,6 +172,7 @@ interface DataTableProps<TData, TValue> {
|
||||
as?: "div" | "table";
|
||||
paginationClassName?: string;
|
||||
rowClassName?: string | ((row: Row<TData>) => string);
|
||||
cellClassName?: (cell: Cell<TData, unknown>) => string;
|
||||
wrapperClassName?: string;
|
||||
tableClassName?: string;
|
||||
searchClassName?: string;
|
||||
@@ -189,6 +196,7 @@ interface DataTableProps<TData, TValue> {
|
||||
keepStateInLocalStorage?: boolean;
|
||||
paginationPaddingClassName?: string;
|
||||
tableCellClassName?: string;
|
||||
tableHeadClassName?: string;
|
||||
initialSelectionState?: RowSelectionState;
|
||||
initialPageSize?: number;
|
||||
uniqueKey?: string;
|
||||
@@ -232,6 +240,7 @@ export function DataTable<TData, TValue>({
|
||||
isFetching = false,
|
||||
paginationClassName,
|
||||
rowClassName,
|
||||
cellClassName,
|
||||
wrapperClassName,
|
||||
as = "table",
|
||||
aboveTable,
|
||||
@@ -256,6 +265,7 @@ export function DataTable<TData, TValue>({
|
||||
keepStateInLocalStorage = true,
|
||||
paginationPaddingClassName,
|
||||
tableCellClassName,
|
||||
tableHeadClassName,
|
||||
initialPageSize = 10,
|
||||
uniqueKey,
|
||||
resetRowSelectionOnSearch = true,
|
||||
@@ -536,6 +546,10 @@ export function DataTable<TData, TValue>({
|
||||
key={header.id}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
className={cn(
|
||||
tableHeadClassName,
|
||||
header.column.columnDef.meta?.className,
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -595,7 +609,12 @@ export function DataTable<TData, TValue>({
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCellComponent
|
||||
key={cell.id}
|
||||
className={cn("relative", tableCellClassName)}
|
||||
className={cn(
|
||||
"relative",
|
||||
tableCellClassName,
|
||||
cell.column.columnDef.meta?.className,
|
||||
cellClassName?.(cell),
|
||||
)}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Cell } from "@tanstack/react-table";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACTIONS_COLUMN_ID,
|
||||
ENABLED_COLUMN_ID,
|
||||
fadeDisabledRowCells,
|
||||
isInteractiveCell,
|
||||
} from "./disabledRowCells";
|
||||
|
||||
// Contract: cells of a disabled row fade out, except the toggle and actions
|
||||
// cells, which must stay fully visible so the row can be re-enabled or removed.
|
||||
type Row = { enabled: boolean };
|
||||
|
||||
const cellOf = (enabled: boolean, columnId: string) =>
|
||||
({
|
||||
row: { original: { enabled } },
|
||||
column: { id: columnId },
|
||||
}) as unknown as Cell<Row, unknown>;
|
||||
|
||||
describe("fadeDisabledRowCells", () => {
|
||||
it("fades a regular cell of a disabled row", () => {
|
||||
expect(fadeDisabledRowCells(cellOf(false, "name"))).toBe("opacity-50");
|
||||
});
|
||||
|
||||
it("keeps the toggle cell of a disabled row fully visible", () => {
|
||||
expect(fadeDisabledRowCells(cellOf(false, ENABLED_COLUMN_ID))).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the actions cell of a disabled row fully visible", () => {
|
||||
expect(fadeDisabledRowCells(cellOf(false, ACTIONS_COLUMN_ID))).toBe("");
|
||||
});
|
||||
|
||||
it("reports toggle and actions columns as interactive, others not", () => {
|
||||
expect(isInteractiveCell(ENABLED_COLUMN_ID)).toBe(true);
|
||||
expect(isInteractiveCell(ACTIONS_COLUMN_ID)).toBe(true);
|
||||
expect(isInteractiveCell("name")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fade any cell of an enabled row", () => {
|
||||
expect(fadeDisabledRowCells(cellOf(true, "name"))).toBe("");
|
||||
expect(fadeDisabledRowCells(cellOf(true, ENABLED_COLUMN_ID))).toBe("");
|
||||
expect(fadeDisabledRowCells(cellOf(true, ACTIONS_COLUMN_ID))).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Cell } from "@tanstack/react-table";
|
||||
|
||||
/** Column id shared by the enable/disable toggle column across tables. */
|
||||
export const ENABLED_COLUMN_ID = "enabled";
|
||||
|
||||
/** Column id shared by the "three dots" action column across tables. */
|
||||
export const ACTIONS_COLUMN_ID = "actions";
|
||||
|
||||
const ALWAYS_VISIBLE_COLUMN_IDS: ReadonlyArray<string> = [
|
||||
ENABLED_COLUMN_ID,
|
||||
ACTIONS_COLUMN_ID,
|
||||
];
|
||||
|
||||
const FADED_CELL_CLASS = "opacity-50";
|
||||
|
||||
/**
|
||||
* Whether a column hosts its own control (toggle, action menu). Tables that
|
||||
* open an editor on row click should ignore clicks landing in these cells.
|
||||
*/
|
||||
export const isInteractiveCell = (columnId: string): boolean =>
|
||||
ALWAYS_VISIBLE_COLUMN_IDS.includes(columnId);
|
||||
|
||||
type Toggleable = { enabled: boolean };
|
||||
|
||||
/**
|
||||
* Cell class for tables whose rows can be switched off: every cell of a
|
||||
* disabled row fades out except the toggle and actions cells, which stay
|
||||
* fully visible so the row can still be re-enabled or removed.
|
||||
*/
|
||||
export const fadeDisabledRowCells = <TData extends Toggleable>(
|
||||
cell: Cell<TData, unknown>,
|
||||
): string => {
|
||||
if (cell.row.original.enabled) return "";
|
||||
if (isInteractiveCell(cell.column.id)) return "";
|
||||
return FADED_CELL_CLASS;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Smallest viewport (Tailwind breakpoint) at which the toggle column shows. */
|
||||
export type EnabledColumnBreakpoint = "xl" | "2xl";
|
||||
|
||||
/**
|
||||
* Column classes for the enable/disable toggle column. The column is a
|
||||
* shortcut for the Enable/Disable entry in each row's action menu, so it is
|
||||
* hidden on narrower viewports to keep the table from overflowing
|
||||
* horizontally. `xl` (1280px) suits most tables; the widest ones (legacy
|
||||
* routes) wait for `2xl` (1536px).
|
||||
*/
|
||||
export const ENABLED_COLUMN_CLASS: Record<EnabledColumnBreakpoint, string> = {
|
||||
xl: "hidden xl:table-cell",
|
||||
"2xl": "hidden 2xl:table-cell",
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import { AnnouncementVariant } from "@components/ui/AnnouncementBanner";
|
||||
import { deploymentAnnouncement } from "@utils/announcement";
|
||||
import loadConfig from "@utils/config";
|
||||
import { isNetBirdCloud } from "@utils/netbird";
|
||||
import md5 from "crypto-js/md5";
|
||||
import React, {
|
||||
createContext,
|
||||
@@ -10,7 +13,8 @@ import React, {
|
||||
import { useMSP } from "@/cloud/msp/contexts/MSPProvider";
|
||||
import { trialExpiresInfo, usageLimitInfo } from "@/contexts/BillingProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { isNetBirdCloud } from "@utils/netbird";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const ANNOUNCEMENTS_URL =
|
||||
"https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json";
|
||||
@@ -179,6 +183,18 @@ export default function AnnouncementProvider({ children }: Readonly<Props>) {
|
||||
allAnnouncements.unshift(...mspAnnouncements);
|
||||
}
|
||||
|
||||
// The operator's own announcement for this deployment
|
||||
// (NETBIRD_ANNOUNCEMENT) stays up ahead of the remote and MSP ones;
|
||||
// only the billing warnings below, when they open, come before it.
|
||||
const deployment = deploymentAnnouncement(config.announcement);
|
||||
if (deployment) {
|
||||
allAnnouncements.unshift({
|
||||
...deployment,
|
||||
hash: md5(deployment.text).toString(),
|
||||
isOpen: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Add billing announcements (initially closed, opened by BillingProvider)
|
||||
allAnnouncements.unshift({
|
||||
...trialExpiresInfo,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RefusalKind, resolveRefusedUser } from "@/contexts/UsersProvider";
|
||||
|
||||
// Importing the provider pulls in the runtime config and, through the pending
|
||||
// screen, an SVG import vitest does not transform. Neither reaches the pure
|
||||
// decision under test.
|
||||
vi.mock("@utils/config", () => ({ default: () => ({}) }));
|
||||
vi.mock("@components/NetBirdLogo", () => ({ NetBirdLogo: () => null }));
|
||||
|
||||
// What each management version returns for a user awaiting approval.
|
||||
const blocked = { code: 403, message: "user is blocked" };
|
||||
const listPending = {
|
||||
code: 403,
|
||||
message: "failed to validate user permissions: user is pending approval",
|
||||
};
|
||||
const currentPendingNamed = {
|
||||
code: 403,
|
||||
message:
|
||||
"failed to validate user permissions: user is pending approval by owner ma****k@acme-corp.com",
|
||||
};
|
||||
|
||||
const settled = { isCurrentLoading: false, isListLoading: false };
|
||||
|
||||
describe("resolveRefusedUser", () => {
|
||||
it("prefers the response that names the owner", () => {
|
||||
expect(
|
||||
resolveRefusedUser({
|
||||
currentError: currentPendingNamed,
|
||||
listError: listPending,
|
||||
...settled,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: RefusalKind.PendingApproval,
|
||||
error: currentPendingNamed,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the list on management that reports pending as blocked", () => {
|
||||
expect(
|
||||
resolveRefusedUser({
|
||||
currentError: blocked,
|
||||
listError: listPending,
|
||||
...settled,
|
||||
}),
|
||||
).toEqual({ kind: RefusalKind.PendingApproval, error: listPending });
|
||||
});
|
||||
|
||||
// The blocked branch used to conclude here, sending a user awaiting approval
|
||||
// to the blocked screen because the response saying otherwise had not landed.
|
||||
it("waits for the list rather than concluding blocked while it is in flight", () => {
|
||||
expect(
|
||||
resolveRefusedUser({
|
||||
currentError: blocked,
|
||||
isCurrentLoading: false,
|
||||
isListLoading: true,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("waits for the named response rather than settling for the list", () => {
|
||||
expect(
|
||||
resolveRefusedUser({
|
||||
listError: listPending,
|
||||
isCurrentLoading: true,
|
||||
isListLoading: false,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes a genuinely blocked user once both have settled", () => {
|
||||
expect(
|
||||
resolveRefusedUser({
|
||||
currentError: blocked,
|
||||
listError: blocked,
|
||||
...settled,
|
||||
}),
|
||||
).toEqual({ kind: RefusalKind.Blocked, error: blocked });
|
||||
});
|
||||
|
||||
it("leaves an unrefused user alone", () => {
|
||||
expect(resolveRefusedUser({ ...settled })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useOidc } from "@axa-fr/react-oidc";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import useFetchApi from "@utils/api";
|
||||
import useFetchApi, { type ErrorResponse } from "@utils/api";
|
||||
import loadConfig from "@utils/config";
|
||||
import React, { useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import PermissionsProvider from "@/contexts/PermissionsProvider";
|
||||
import { Role, User } from "@/interfaces/User";
|
||||
import { PendingApproval } from "@/modules/users/PendingApproval";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
@@ -13,11 +15,52 @@ type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export enum RefusalKind {
|
||||
PendingApproval = "pending_approval",
|
||||
Blocked = "blocked",
|
||||
}
|
||||
|
||||
export const resolveRefusedUser = ({
|
||||
currentError,
|
||||
listError,
|
||||
isCurrentLoading,
|
||||
isListLoading,
|
||||
}: {
|
||||
currentError?: ErrorResponse;
|
||||
listError?: ErrorResponse;
|
||||
isCurrentLoading: boolean;
|
||||
isListLoading: boolean;
|
||||
}): { kind: RefusalKind; error: ErrorResponse } | undefined => {
|
||||
const readError = (error?: ErrorResponse) => {
|
||||
const message = error?.message?.toLowerCase();
|
||||
if (!error || !message) return undefined;
|
||||
if (message.includes("pending approval"))
|
||||
return { kind: RefusalKind.PendingApproval, error };
|
||||
if (message.includes("blocked"))
|
||||
return { kind: RefusalKind.Blocked, error };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const currentUserError = readError(currentError);
|
||||
if (currentUserError?.kind === RefusalKind.PendingApproval)
|
||||
return currentUserError;
|
||||
if (isCurrentLoading) return undefined;
|
||||
|
||||
const listUserError = readError(listError);
|
||||
if (listUserError?.kind === RefusalKind.PendingApproval) return listUserError;
|
||||
if (isListLoading) return undefined;
|
||||
|
||||
return currentUserError ?? listUserError;
|
||||
};
|
||||
|
||||
const UsersContext = React.createContext(
|
||||
{} as {
|
||||
users: User[] | undefined;
|
||||
refresh: () => void;
|
||||
isLoading: boolean;
|
||||
// Older management reports a pending user as merely blocked on
|
||||
// /users/current; this call still says pending, so it is the fallback.
|
||||
usersError?: ErrorResponse;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -37,6 +80,7 @@ export default function UsersProvider({ children }: Readonly<Props>) {
|
||||
data: users,
|
||||
mutate,
|
||||
isLoading,
|
||||
error: usersError,
|
||||
} = useFetchApi<User[]>("/users?service_user=false", true);
|
||||
const {
|
||||
data: serviceUsers,
|
||||
@@ -57,6 +101,7 @@ export default function UsersProvider({ children }: Readonly<Props>) {
|
||||
<UsersContext.Provider
|
||||
value={{
|
||||
users: allUsers,
|
||||
usersError,
|
||||
refresh,
|
||||
isLoading: isLoading || isLoadingServiceUsers,
|
||||
}}
|
||||
@@ -69,7 +114,9 @@ export default function UsersProvider({ children }: Readonly<Props>) {
|
||||
export const useUsers = () => React.useContext(UsersContext);
|
||||
|
||||
const UserProfileProvider = ({ children }: Props) => {
|
||||
const { users, isLoading: isAllUsersLoading } = useUsers();
|
||||
const { logout } = useOidc();
|
||||
const router = useRouter();
|
||||
const { users, usersError, isLoading: isAllUsersLoading } = useUsers();
|
||||
const {
|
||||
data: user,
|
||||
error,
|
||||
@@ -93,6 +140,41 @@ const UserProfileProvider = ({ children }: Props) => {
|
||||
};
|
||||
}, [loggedInUser]);
|
||||
|
||||
const refusal = resolveRefusedUser({
|
||||
currentError: error,
|
||||
listError: usersError,
|
||||
isCurrentLoading: isLoading,
|
||||
isListLoading: isAllUsersLoading,
|
||||
});
|
||||
|
||||
const blockedUrl =
|
||||
refusal?.kind === RefusalKind.Blocked
|
||||
? `/error?${new URLSearchParams({
|
||||
code: String(refusal.error.code),
|
||||
message: encodeURIComponent(refusal.error.message),
|
||||
type: "user-status",
|
||||
}).toString()}`
|
||||
: undefined;
|
||||
|
||||
// Blocked is a dead end rather than a wait, so it keeps the error page. The
|
||||
// navigation is an effect: calling it while rendering updates the router
|
||||
// mid-render, which React warns about and can run twice.
|
||||
useEffect(() => {
|
||||
if (blockedUrl) router.replace(blockedUrl);
|
||||
}, [blockedUrl, router]);
|
||||
|
||||
if (refusal?.kind === RefusalKind.PendingApproval) {
|
||||
return (
|
||||
<PendingApproval
|
||||
error={refusal.error}
|
||||
onRefresh={() => router.push("/")}
|
||||
onLogout={() => logout("/", { client_id: config.clientId })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (blockedUrl) return <FullScreenLoading />;
|
||||
|
||||
// Show loading only when we're still loading and don't have user data
|
||||
if (isLoading || !loggedInUser) {
|
||||
return <FullScreenLoading />;
|
||||
|
||||
Loaded 20 of 39 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user