Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]");
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/team-users-approval-and-billing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
21 changes: 15 additions & 6 deletions src/app/error/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -57,19 +58,27 @@ 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";
};

const getDescription = () => {
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.";
};

Expand Down Expand Up @@ -98,15 +107,15 @@ 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
</Button>
)}

<Button variant="primary" size="sm" onClick={handleLogout}>
{isBlockedUser || isPendingApproval ? "Sign Out" : "Logout"}
{isBlockedUser ? "Sign Out" : "Logout"}
<ArrowRightIcon size={16} />
</Button>
</div>
Expand Down
50 changes: 44 additions & 6 deletions src/components/Steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -35,8 +56,12 @@ const Step = ({
center = false,
horizontal,
disabled = false,
status,
size = "default",
className,
}: StepProps) => {
const sizing = stepSizes[size];

return (
<div
className={cn(
Expand All @@ -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}
Expand Down
72 changes: 72 additions & 0 deletions src/modules/users/PendingApproval.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid={"netbird-logo"} />,
}));

// 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(
<PendingApproval error={error} onRefresh={vi.fn()} onLogout={vi.fn()} />,
);
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");
});
});
122 changes: 122 additions & 0 deletions src/modules/users/PendingApproval.tsx
Original file line number Diff line number Diff line change
@@ -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: <CheckIcon size={16} />,
},
{
label: "Waiting for Approval",
status: "current",
icon: <Loader2Icon size={16} className={"animate-spin text-netbird"} />,
},
{
label: "Join Account",
status: "upcoming",
icon: <UserCircleIcon size={16} />,
},
] 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 (
<div
className={
"min-h-screen w-full bg-nb-gray-950 flex flex-col items-center justify-center gap-8 px-4 py-10"
}
data-testid={"pending-approval"}
>
<NetBirdLogo size={"large"} mobile={false} />

<div
className={
"w-full max-w-2xl bg-nb-gray-940 border border-nb-gray-910 rounded-lg px-6 sm:px-12 py-8 sm:py-9 flex flex-col gap-10"
}
>
<Steps horizontal={true} className={"pt-0 w-full"}>
{steps.map(({ label, status, icon }, index) => (
<Steps.Step
key={label}
step={icon}
status={status}
horizontal={true}
size={"large"}
line={index < steps.length - 1}
className={"flex-1 pb-0"}
>
<span
className={cn(
"text-sm text-center",
status === "upcoming" ? "text-nb-gray-400" : "text-white",
)}
>
{label}
</span>
</Steps.Step>
))}
</Steps>

<Paragraph className={"block max-w-md mx-auto text-center"}>
Your organization requires new users to be manually approved before
joining.{" "}
{owner ? (
<>
Ask the owner of the account at{" "}
<span className={"text-nb-gray-100"}>{owner}</span> to approve
your access.
</>
) : (
"Ask the owner of the account to approve your access."
)}
</Paragraph>

<div className={"flex flex-col sm:flex-row gap-3 justify-center"}>
<Button variant={"secondary"} size={"sm"} onClick={onRefresh}>
<RefreshCwIcon size={16} />
Refresh
</Button>
<Button variant={"default-outline"} size={"sm"} onClick={onLogout}>
<LogOut size={16} />
Log Out
</Button>
</div>
</div>

<Paragraph className={"text-sm"}>
Need help?
<InlineLink href={`mailto:${SUPPORT_EMAIL}`}>
{SUPPORT_EMAIL}
</InlineLink>
</Paragraph>
</div>
);
};
Loading