From 8625e4111470c724260a2ef20382e73aff27839a Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Mon, 14 Sep 2026 10:43:16 +0200 Subject: [PATCH 1/2] 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. --- src/app/error/page.tsx | 21 +++- src/components/Steps.tsx | 50 ++++++++- src/modules/users/PendingApproval.test.tsx | 72 ++++++++++++ src/modules/users/PendingApproval.tsx | 122 +++++++++++++++++++++ 4 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 src/modules/users/PendingApproval.test.tsx create mode 100644 src/modules/users/PendingApproval.tsx diff --git a/src/app/error/page.tsx b/src/app/error/page.tsx index 72a6a3f2c..3b8637dd8 100644 --- a/src/app/error/page.tsx +++ b/src/app/error/page.tsx @@ -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 ( + + ); + } + 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() {
- {!isBlockedUser && !isPendingApproval && ( + {!isBlockedUser && (
diff --git a/src/components/Steps.tsx b/src/components/Steps.tsx index 5d18afef1..b86a876ce 100644 --- a/src/components/Steps.tsx +++ b/src/components/Steps.tsx @@ -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 (
)}
{step} diff --git a/src/modules/users/PendingApproval.test.tsx b/src/modules/users/PendingApproval.test.tsx new file mode 100644 index 000000000..6c10581ae --- /dev/null +++ b/src/modules/users/PendingApproval.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PendingApproval } from "@/modules/users/PendingApproval"; + +// The logo is an SVG import vitest does not transform, and it carries no copy +// these tests care about. +vi.mock("@components/NetBirdLogo", () => ({ + NetBirdLogo: () =>
, +})); + +// Management names the owner inside the refusal itself. By the time it reaches +// the dashboard it has been lowercased and wrapped by +// NewPermissionValidationError, so both are pinned here. +const withOwner = { + code: 403, + message: + "failed to validate user permissions: user is pending approval by owner ad****n@example.com", +}; +const withoutOwner = { + code: 403, + message: "failed to validate user permissions: user is pending approval", +}; + +// The copy is split across elements for styling, so it is read back as one +// whitespace-normalised string. +const copyFor = (error: { code: number; message: string } | null) => { + const { container } = render( + , + ); + return container.textContent?.replace(/\s+/g, " ").trim() ?? ""; +}; + +afterEach(cleanup); + +describe("PendingApproval", () => { + it("names the owner carried in the refusal message", () => { + expect(copyFor(withOwner)).toContain( + "Ask the owner of the account at ad****n@example.com to approve your access.", + ); + }); + + it("asks for the owner without an address when management named none", () => { + const copy = copyFor(withoutOwner); + expect(copy).toContain( + "Ask the owner of the account to approve your access.", + ); + expect(copy).not.toContain("@example.com"); + }); + + it("falls back to the addressless copy when there is no error at all", () => { + expect(copyFor(null)).toContain( + "Ask the owner of the account to approve your access.", + ); + }); + + it("states the approval requirement either way", () => { + expect(copyFor(withOwner)).toContain( + "Your organization requires new users to be manually approved before joining.", + ); + cleanup(); + expect(copyFor(withoutOwner)).toContain( + "Your organization requires new users to be manually approved before joining.", + ); + }); + + it("walks the stepper to the approval step", () => { + const copy = copyFor(null); + expect(copy).toContain("Account Created"); + expect(copy).toContain("Waiting for Approval"); + expect(copy).toContain("Join Account"); + }); +}); diff --git a/src/modules/users/PendingApproval.tsx b/src/modules/users/PendingApproval.tsx new file mode 100644 index 000000000..0428c3f7d --- /dev/null +++ b/src/modules/users/PendingApproval.tsx @@ -0,0 +1,122 @@ +"use client"; + +import Button from "@components/Button"; +import InlineLink from "@components/InlineLink"; +import { NetBirdLogo } from "@components/NetBirdLogo"; +import Paragraph from "@components/Paragraph"; +import Steps from "@components/Steps"; +import { cn } from "@utils/helpers"; +import { + CheckIcon, + Loader2Icon, + LogOut, + RefreshCwIcon, + UserCircleIcon, +} from "lucide-react"; +import * as React from "react"; + +const SUPPORT_EMAIL = "support@netbird.io"; + +const parseApproverEmail = (message?: string): string => + message?.match(/[^\s@]+@[a-z0-9.-]+\.[a-z]{2,}/i)?.[0] ?? ""; + +const steps = [ + { + label: "Account Created", + status: "complete", + icon: , + }, + { + label: "Waiting for Approval", + status: "current", + icon: , + }, + { + label: "Join Account", + status: "upcoming", + icon: , + }, +] as const; + +type Props = { + // The refusal from management, which names the owner who can approve. + error?: { message?: string } | null; + onRefresh: () => void; + onLogout: () => void; +}; + +export const PendingApproval = ({ error, onRefresh, onLogout }: Props) => { + const owner = parseApproverEmail(error?.message); + + return ( +
+ + +
+ + {steps.map(({ label, status, icon }, index) => ( + + + {label} + + + ))} + + + + Your organization requires new users to be manually approved before + joining.{" "} + {owner ? ( + <> + Ask the owner of the account at{" "} + {owner} to approve + your access. + + ) : ( + "Ask the owner of the account to approve your access." + )} + + +
+ + +
+
+ + + Need help? + + {SUPPORT_EMAIL} + + +
+ ); +}; From ac9d76adf4d8405d4655ea5c379fe07fafb90744 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Mon, 14 Sep 2026 11:11:12 +0200 Subject: [PATCH 2/2] 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. --- e2e/helpers/auth.ts | 2 +- e2e/tests/login.spec.ts | 2 +- e2e/tests/team-users-approval-and-billing.spec.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index 184f8bfd4..0c1ee2afe 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -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]"); diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts index c081d208f..7d904300c 100644 --- a/e2e/tests/login.spec.ts +++ b/e2e/tests/login.spec.ts @@ -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 { diff --git a/e2e/tests/team-users-approval-and-billing.spec.ts b/e2e/tests/team-users-approval-and-billing.spec.ts index 89628585f..88b054bee 100644 --- a/e2e/tests/team-users-approval-and-billing.spec.ts +++ b/e2e/tests/team-users-approval-and-billing.spec.ts @@ -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(); });