Skip to content
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
83 changes: 83 additions & 0 deletions src/contexts/UsersProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
88 changes: 85 additions & 3 deletions src/contexts/UsersProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,66 @@
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();

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;
},
);

Expand All @@ -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,
Expand All @@ -57,6 +101,7 @@ export default function UsersProvider({ children }: Readonly<Props>) {
<UsersContext.Provider
value={{
users: allUsers,
usersError,
refresh,
isLoading: isLoading || isLoadingServiceUsers,
}}
Expand All @@ -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,
Expand All @@ -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 />;
Expand Down
Loading
Loading