Compare commits

...

1 Commits

Author SHA1 Message Date
Maycon Santos
b79c6615b4 Add user approval feature (#486)
Some checks failed
build and push / build_n_push (push) Has been cancelled
implements a user approval feature that allows administrators to manually approve new users before they can access the system. The feature adds approval workflow controls and error handling for blocked/pending users.

Adds user approval toggle in authentication settings
Implements approve/reject actions for pending users in the users table
Creates error page for blocked/pending approval scenarios
2025-09-02 15:25:30 +02:00
15 changed files with 447 additions and 62 deletions

View File

@@ -289,6 +289,7 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) {
const neverLoggedIn = dayjs(user.last_login).isBefore(
dayjs().subtract(1000, "years"),
);
const isPendingApproval = user?.pending_approval;
return (
<Card>
@@ -328,18 +329,20 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) {
{!isServiceUser && (
<>
{!user.is_current && user.role != Role.Owner && (
<Card.ListItem
tooltip={false}
label={
<>
<Ban size={16} />
Block User
</>
}
value={<UserBlockCell user={user} isUserPage={true} />}
/>
)}
{!user.is_current &&
user.role != Role.Owner &&
!isPendingApproval && (
<Card.ListItem
tooltip={false}
label={
<>
<Ban size={16} />
Block User
</>
}
value={<UserBlockCell user={user} isUserPage={true} />}
/>
)}
<Card.ListItem
label={

115
src/app/error/page.tsx Normal file
View File

@@ -0,0 +1,115 @@
"use client";
import { useOidc } from "@axa-fr/react-oidc";
import Button from "@components/Button";
import Paragraph from "@components/Paragraph";
import loadConfig from "@utils/config";
import { ArrowRightIcon, RefreshCw } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
const config = loadConfig();
export default function ErrorPage() {
const { logout, isAuthenticated } = useOidc();
const router = useRouter();
const searchParams = useSearchParams();
const [error, setError] = useState<{
code: number;
message: string;
type: string;
} | null>(null);
useEffect(() => {
// Get error details from URL params
const code = searchParams.get("code");
const message = searchParams.get("message");
const type = searchParams.get("type");
if (code && message) {
setError({
code: parseInt(code),
message: decodeURIComponent(message),
type: type || "error",
});
}
}, [searchParams]);
const handleLogout = () => {
// Use the same logout pattern as OIDCError
logout("/", { client_id: config.clientId });
};
const handleRetry = () => {
router.push("/");
};
if (!isAuthenticated) {
// If not authenticated, redirect to home
router.push("/");
return null;
}
const isBlockedUser =
error?.code === 403 && error?.message?.toLowerCase().includes("blocked");
const isPendingApproval =
error?.code === 403 &&
error?.message?.toLowerCase().includes("pending approval");
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.";
};
return (
<div className="flex items-center justify-center flex-col h-screen max-w-xl mx-auto">
<div className="bg-nb-gray-930 mb-3 border border-nb-gray-900 h-12 w-12 rounded-md flex items-center justify-center">
<NetBirdIcon size={23} />
</div>
<h1 className="text-center mt-2">{getTitle()}</h1>
<Paragraph className="text-center mt-2 block">
{getDescription()}
</Paragraph>
{error && (
<div className="bg-nb-gray-930 border border-nb-gray-800 rounded-md p-4 mt-4 max-w-md font-mono mb-2">
<div className="text-center text-sm text-netbird">
<div>response_message: {error.message}</div>
</div>
</div>
)}
<Paragraph className="text-center mt-2 text-sm">
If you believe this is an error, please contact your administrator.
</Paragraph>
<div className="mt-5 space-y-3">
{!isBlockedUser && !isPendingApproval && (
<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"}
<ArrowRightIcon size={16} />
</Button>
</div>
</div>
);
}

View File

@@ -8,12 +8,12 @@ export const NotificationCountBadge = ({ count = 0 }: Props) => {
return count ? (
<div
className={cn(
count <= 9 ? "w-5 h-5" : "py-2.5 px-2",
"relative bg-netbird flex items-center justify-center rounded-full text-white !leading-[0] text-xs font-semibold",
count <= 9 ? "w-4 h-4" : "py-2 px-1.5",
"relative bg-netbird flex items-center justify-center rounded-full text-white !leading-[0] text-[0.6rem] font-semibold",
)}
>
<span className="animate-ping absolute left-0 inline-flex h-full w-full rounded-full bg-netbird opacity-20"></span>
{count || 0}
<span className={"relative -left-[0.5px]"}>{count || 0}</span>
</div>
) : null;
};

View File

@@ -62,18 +62,24 @@ const UserProfileProvider = ({ children }: Props) => {
}
}, [user, error, users, isLoading, isAllUsersLoading]);
const data = useMemo(() => {
return {
loggedInUser,
};
}, [loggedInUser]);
return !isLoading && loggedInUser ? (
// Show loading only when we're still loading and don't have user data
if (isLoading || !loggedInUser) {
return <FullScreenLoading />;
}
// For blocked or pending approval users, we still need to provide the context
// so they can access their user data on the blocked page
return (
<UserProfileContext.Provider value={data}>
<PermissionsProvider user={loggedInUser}>{children}</PermissionsProvider>
</UserProfileContext.Provider>
) : (
<FullScreenLoading />
);
};

View File

@@ -7,6 +7,7 @@ export interface Account {
settings: {
extra: {
peer_approval_enabled: boolean;
user_approval_required: boolean;
};
peer_login_expiration_enabled: boolean;
peer_login_expiration: number;

View File

@@ -10,6 +10,7 @@ export interface User {
is_current?: boolean;
is_service_user?: boolean;
is_blocked?: boolean;
pending_approval?: boolean;
last_login?: Date;
permissions: Permissions;
}

View File

@@ -253,6 +253,22 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "user.approve")
return (
<div className={"inline"}>
User <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value> was approved
</div>
);
if (event.activity_code == "user.reject")
return (
<div className={"inline"}>
User <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value> was rejected
</div>
);
/**
* Service User
*/

View File

@@ -19,6 +19,7 @@ const ACTION_COLOR_MAPPING: Record<string, ActionStatus> = {
delete: ActionStatus.ERROR,
revoke: ActionStatus.ERROR,
block: ActionStatus.ERROR,
reject: ActionStatus.ERROR,
// Warning actions
overuse: ActionStatus.WARNING,

View File

@@ -23,6 +23,7 @@ import {
CalendarClock,
ExternalLinkIcon,
ShieldIcon,
ShieldUserIcon,
TimerResetIcon,
} from "lucide-react";
import React, { useState } from "react";
@@ -52,6 +53,19 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
}
});
/**
* User approval required
*/
const [userApprovalRequired, setUserApprovalRequired] = useState<boolean>(
() => {
try {
return account?.settings?.extra?.user_approval_required || false;
} catch (error) {
return false;
}
},
);
// Peer Expiration
const [
loginExpiration,
@@ -86,6 +100,7 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
const { hasChanges, updateRef } = useHasChanges([
peerApproval,
userApprovalRequired,
loginExpiration,
expiresIn,
expireInterval,
@@ -118,6 +133,7 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
extra: {
...account.settings?.extra,
peer_approval_enabled: peerApproval,
user_approval_required: userApprovalRequired,
},
},
} as Account)
@@ -125,6 +141,7 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
mutate("/accounts");
updateRef([
peerApproval,
userApprovalRequired,
loginExpiration,
expiresIn,
expireInterval,
@@ -181,6 +198,27 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
</div>
<div className={"flex flex-col gap-6 w-full mt-8 mb-3"}>
<div className={"flex flex-col"}>
<FancyToggleSwitch
value={userApprovalRequired}
onChange={setUserApprovalRequired}
dataCy={"user-approval-required"}
label={
<>
<ShieldUserIcon size={15} />
User Approval Required
</>
}
helpText={
<>
Require manual approval for new users joining via <br />
domain matching. Users will be blocked until approved.
</>
}
disabled={!permission.settings.update}
/>
</div>
<div className={"flex flex-col"}>
<FancyToggleSwitch
value={loginExpiration}

View File

@@ -6,6 +6,7 @@ import DataTableHeader from "@components/table/DataTableHeader";
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import GetStartedTest from "@components/ui/GetStartedTest";
import { NotificationCountBadge } from "@components/ui/NotificationCountBadge";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import useFetchApi from "@utils/api";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
@@ -88,6 +89,12 @@ export const UsersTableColumns: ColumnDef<User>[] = [
/>
),
},
{
id: "approval_required",
accessorKey: "approval_required",
sortingFn: "basic",
accessorFn: (u) => u?.pending_approval,
},
{
accessorKey: "id",
header: "",
@@ -127,6 +134,8 @@ export default function UsersTable({
);
const router = useRouter();
const pendingApprovalCount =
users?.filter((u) => u.pending_approval).length || 0;
return (
<DataTable
@@ -139,6 +148,7 @@ export default function UsersTable({
data={users}
columnVisibility={{
is_current: false,
approval_required: false,
}}
onRowClick={(row) => {
router.push(`/team/user?id=${row.original.id}`);
@@ -185,18 +195,56 @@ export default function UsersTable({
/>
)}
>
{(table) => (
<>
<DataTableRowsPerPage table={table} disabled={users?.length == 0} />
<DataTableRefreshButton
isDisabled={users?.length == 0}
onClick={() => {
mutate("/users?service_user=false");
mutate("/groups");
}}
/>
</>
)}
{(table) => {
if (
pendingApprovalCount == 0 &&
table.getColumn("approval_required")?.getFilterValue() === true
) {
table.setColumnFilters([]);
}
return (
<>
{pendingApprovalCount > 0 && (
<Button
disabled={users?.length == 0}
onClick={() => {
table.setPageIndex(0);
let current =
table.getColumn("approval_required")?.getFilterValue() ===
undefined
? true
: undefined;
table.setColumnFilters([
{
id: "approval_required",
value: current,
},
]);
}}
variant={
table.getColumn("approval_required")?.getFilterValue() ===
true
? "tertiary"
: "secondary"
}
>
Pending Approvals
<NotificationCountBadge count={pendingApprovalCount} />
</Button>
)}
<DataTableRowsPerPage table={table} disabled={users?.length == 0} />
<DataTableRefreshButton
isDisabled={users?.length == 0}
onClick={() => {
mutate("/users?service_user=false");
mutate("/groups");
}}
/>
</>
);
}}
</DataTable>
);
}

View File

@@ -2,7 +2,7 @@ import Button from "@components/Button";
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import { isNetBirdHosted } from "@utils/netbird";
import { Trash2 } from "lucide-react";
import { Trash2, XCircle } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { useSWRConfig } from "swr";
@@ -36,6 +36,42 @@ export default function UserActionCell({
});
};
const approveUser = async () => {
const name = user.name || "User";
notify({
title: `'${name}' approved`,
description: "User was successfully approved.",
promise: userRequest.post({}, `/${user.id}/approve`).then(() => {
mutate(`/users?service_user=${serviceUser}`);
}),
loadingMessage: "Approving the user...",
});
};
const rejectUser = async () => {
const name = user.name || "User";
const choice = await confirm({
title: `Reject '${name}'?`,
description:
"Rejecting this user will remove them from the account permanently. This action cannot be undone.",
confirmText: "Reject",
cancelText: "Cancel",
type: "danger",
maxWidthClass: "max-w-md",
});
if (!choice) return;
notify({
title: `'${name}' rejected`,
description: "User was successfully rejected and removed.",
promise: userRequest.del("", `/${user.id}/reject`).then(() => {
mutate(`/users?service_user=${serviceUser}`);
}),
loadingMessage: "Rejecting the user...",
});
};
const openConfirm = async () => {
const name = user.name || "User";
const choice = await confirm({
@@ -44,6 +80,7 @@ export default function UserActionCell({
"Deleting this user will remove their devices and remove dashboard access. This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
maxWidthClass: "max-w-md",
type: "danger",
});
if (!choice) return;
@@ -55,21 +92,50 @@ export default function UserActionCell({
return user.is_current;
}, [permission.users.delete, user.is_current]);
const isPendingApproval = user.pending_approval;
const canManageUsers = permission.users.update;
return (
<div className={"flex justify-end pr-4 items-center gap-4"}>
{!serviceUser && isNetBirdHosted() && (
<div className={"flex justify-end pr-4 items-center gap-2"}>
{!serviceUser && isNetBirdHosted() && !isPendingApproval && (
<UserResendInviteButton user={user} />
)}
<Button
variant={"danger-outline"}
size={"sm"}
onClick={openConfirm}
data-cy={"delete-user"}
disabled={disabled}
>
<Trash2 size={16} />
Delete
</Button>
{isPendingApproval && canManageUsers && (
<>
<Button
variant={"secondary"}
size={"xs"}
onClick={approveUser}
data-cy={"approve-user"}
>
Approve
</Button>
<Button
variant={"danger-outline"}
size={"xs"}
className={"!px-3"}
onClick={rejectUser}
data-cy={"reject-user"}
>
<XCircle size={14} />
Reject
</Button>
</>
)}
{!isPendingApproval && (
<Button
variant={"danger-outline"}
size={"sm"}
onClick={openConfirm}
data-cy={"delete-user"}
disabled={disabled}
>
<Trash2 size={16} />
Delete
</Button>
)}
</div>
);
}

View File

@@ -61,6 +61,8 @@ export default function UserBlockCell({ user, isUserPage = false }: Props) {
});
};
if (user?.pending_approval) return;
return !disabled ? (
<div className={"flex"}>
<ToggleSwitch

View File

@@ -10,6 +10,27 @@ export default function UserNameCell({ user }: Readonly<Props>) {
const status = user.status;
const isCurrent = user.is_current;
const getStatusIcon = () => {
if (user?.pending_approval) {
return {
color: "bg-netbird text-netbird-950",
icon: <Clock size={12} />,
};
}
if (status === "blocked") {
return { color: "bg-red-500 text-red-100", icon: <Ban size={12} /> };
}
if (status === "invited") {
return {
color: "bg-yellow-400 text-yellow-900",
icon: <Clock size={12} />,
};
}
return { color: "bg-gray-400", icon: <Clock size={12} /> };
};
const { color, icon } = getStatusIcon();
return (
<div
className={cn("flex gap-4 px-2 py-1 items-center")}
@@ -29,12 +50,10 @@ export default function UserNameCell({ user }: Readonly<Props>) {
<div
className={cn(
"w-5 h-5 absolute -right-1 -bottom-1 bg-nb-gray-930 rounded-full flex items-center justify-center border-2 border-nb-gray-950",
status == "invited" && "bg-yellow-400 text-yellow-900",
status == "blocked" && "bg-red-500 text-red-100",
color,
)}
>
{status == "invited" && <Clock size={12} />}
{status == "blocked" && <Ban size={12} />}
{icon}
</div>
)}
</div>

View File

@@ -1,4 +1,7 @@
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import { cn } from "@utils/helpers";
import { ExternalLinkIcon, HelpCircle } from "lucide-react";
import React from "react";
import { User } from "@/interfaces/User";
@@ -8,23 +11,73 @@ type Props = {
export default function UserStatusCell({ user }: Readonly<Props>) {
const status = user.status;
const isPendingApproval = user.pending_approval;
const getStatusDisplay = () => {
if (isPendingApproval) {
return { text: "Pending Approval", color: "bg-netbird" };
}
if (status === "blocked") {
return { text: "Blocked", color: "bg-red-500" };
}
if (status === "invited") {
return { text: "Pending", color: "bg-yellow-400" };
}
if (status === "active") {
return { text: "Active", color: "bg-green-500" };
}
return { text: status || "Unknown", color: "bg-gray-400" };
};
const { text, color } = getStatusDisplay();
return (
<div
className={cn("flex gap-2.5 items-center text-nb-gray-300 text-sm")}
data-cy={"user-status-cell"}
>
<span
className={cn(
"h-2 w-2 rounded-full",
status == "invited" && "bg-yellow-400",
status == "blocked" && "bg-red-500",
status == "active" && "bg-green-500",
)}
></span>
{status == "invited" && "Pending"}
{status == "blocked" && "Blocked"}
{status == "active" && "Active"}
<div onClick={(e) => e.stopPropagation()}>
<FullTooltip
content={
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
<div>
This user needs to be approved by an administrator before it can
join your organization.
</div>
<div>
If you want to disable approval for new users, go to{" "}
<InlineLink href={"/settings?tab=authentication"}>
Settings
</InlineLink>{" "}
and disable{" "}
<span className={"font-medium text-white"}>
{"'User Approval Required'"}
</span>
.
</div>
<div>
Learn more about{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/approve-users"}
target={"_blank"}
>
User Approval <ExternalLinkIcon size={12} />
</InlineLink>
</div>
</div>
}
interactive={true}
side="right"
disabled={!isPendingApproval}
>
<div
className={cn("flex gap-2.5 items-center text-nb-gray-300 text-sm")}
data-cy={"user-status-cell"}
>
<span className={cn("h-2 w-2 rounded-full", color)}></span>
{text}
{isPendingApproval && (
<HelpCircle size={14} className="text-netbird cursor-help" />
)}
</div>
</FullTooltip>
</div>
);
}

View File

@@ -215,6 +215,7 @@ export function useApiErrorHandling(ignoreError = false) {
const { login } = useOidc();
const currentPath = usePathname();
const { setError } = useErrorBoundary();
if (ignoreError)
return (err: ErrorResponse) => {
console.log(err);
@@ -231,6 +232,21 @@ export function useApiErrorHandling(ignoreError = false) {
if (err.code == 401 && err.message == "token invalid") {
setError(err);
}
// Handle user blocked/pending approval responses
if (err.code == 403 && (
err.message?.toLowerCase().includes("blocked") ||
err.message?.toLowerCase().includes("pending")
)) {
const params = new URLSearchParams({
code: err.code.toString(),
message: encodeURIComponent(err.message),
type: "user-status"
});
window.location.href = `/error?${params.toString()}`;
return Promise.reject(err);
}
if (err.code == 500 && err.message == "internal server error") {
setError(err);
}