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(); }); 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} + + +
+ ); +};