Skip to content

Commit b79c661

Browse files
authored
Add user approval feature (#486)
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
1 parent 5d4e491 commit b79c661

File tree

15 files changed

+447
-62
lines changed

15 files changed

+447
-62
lines changed

src/app/(dashboard)/team/user/page.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) {
289289
const neverLoggedIn = dayjs(user.last_login).isBefore(
290290
dayjs().subtract(1000, "years"),
291291
);
292+
const isPendingApproval = user?.pending_approval;
292293

293294
return (
294295
<Card>
@@ -328,18 +329,20 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) {
328329

329330
{!isServiceUser && (
330331
<>
331-
{!user.is_current && user.role != Role.Owner && (
332-
<Card.ListItem
333-
tooltip={false}
334-
label={
335-
<>
336-
<Ban size={16} />
337-
Block User
338-
</>
339-
}
340-
value={<UserBlockCell user={user} isUserPage={true} />}
341-
/>
342-
)}
332+
{!user.is_current &&
333+
user.role != Role.Owner &&
334+
!isPendingApproval && (
335+
<Card.ListItem
336+
tooltip={false}
337+
label={
338+
<>
339+
<Ban size={16} />
340+
Block User
341+
</>
342+
}
343+
value={<UserBlockCell user={user} isUserPage={true} />}
344+
/>
345+
)}
343346

344347
<Card.ListItem
345348
label={

src/app/error/page.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"use client";
2+
3+
import { useOidc } from "@axa-fr/react-oidc";
4+
import Button from "@components/Button";
5+
import Paragraph from "@components/Paragraph";
6+
import loadConfig from "@utils/config";
7+
import { ArrowRightIcon, RefreshCw } from "lucide-react";
8+
import { useRouter, useSearchParams } from "next/navigation";
9+
import { useEffect, useState } from "react";
10+
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
11+
12+
const config = loadConfig();
13+
14+
export default function ErrorPage() {
15+
const { logout, isAuthenticated } = useOidc();
16+
const router = useRouter();
17+
const searchParams = useSearchParams();
18+
const [error, setError] = useState<{
19+
code: number;
20+
message: string;
21+
type: string;
22+
} | null>(null);
23+
24+
useEffect(() => {
25+
// Get error details from URL params
26+
const code = searchParams.get("code");
27+
const message = searchParams.get("message");
28+
const type = searchParams.get("type");
29+
30+
if (code && message) {
31+
setError({
32+
code: parseInt(code),
33+
message: decodeURIComponent(message),
34+
type: type || "error",
35+
});
36+
}
37+
}, [searchParams]);
38+
39+
const handleLogout = () => {
40+
// Use the same logout pattern as OIDCError
41+
logout("/", { client_id: config.clientId });
42+
};
43+
44+
const handleRetry = () => {
45+
router.push("/");
46+
};
47+
48+
if (!isAuthenticated) {
49+
// If not authenticated, redirect to home
50+
router.push("/");
51+
return null;
52+
}
53+
54+
const isBlockedUser =
55+
error?.code === 403 && error?.message?.toLowerCase().includes("blocked");
56+
const isPendingApproval =
57+
error?.code === 403 &&
58+
error?.message?.toLowerCase().includes("pending approval");
59+
60+
const getTitle = () => {
61+
if (isBlockedUser) return "User Account Blocked";
62+
if (isPendingApproval) return "User Approval Pending";
63+
return "Access Error";
64+
};
65+
66+
const getDescription = () => {
67+
if (isBlockedUser) {
68+
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.";
69+
}
70+
if (isPendingApproval) {
71+
return "Your account is pending approval from an administrator. Please wait for approval before accessing the dashboard.";
72+
}
73+
return "An error occurred while trying to access the dashboard. Please try again or contact your administrator.";
74+
};
75+
76+
return (
77+
<div className="flex items-center justify-center flex-col h-screen max-w-xl mx-auto">
78+
<div className="bg-nb-gray-930 mb-3 border border-nb-gray-900 h-12 w-12 rounded-md flex items-center justify-center">
79+
<NetBirdIcon size={23} />
80+
</div>
81+
82+
<h1 className="text-center mt-2">{getTitle()}</h1>
83+
84+
<Paragraph className="text-center mt-2 block">
85+
{getDescription()}
86+
</Paragraph>
87+
88+
{error && (
89+
<div className="bg-nb-gray-930 border border-nb-gray-800 rounded-md p-4 mt-4 max-w-md font-mono mb-2">
90+
<div className="text-center text-sm text-netbird">
91+
<div>response_message: {error.message}</div>
92+
</div>
93+
</div>
94+
)}
95+
96+
<Paragraph className="text-center mt-2 text-sm">
97+
If you believe this is an error, please contact your administrator.
98+
</Paragraph>
99+
100+
<div className="mt-5 space-y-3">
101+
{!isBlockedUser && !isPendingApproval && (
102+
<Button variant="default-outline" size="sm" onClick={handleRetry}>
103+
<RefreshCw size={16} className="mr-2" />
104+
Try Again
105+
</Button>
106+
)}
107+
108+
<Button variant="primary" size="sm" onClick={handleLogout}>
109+
{isBlockedUser || isPendingApproval ? "Sign Out" : "Logout"}
110+
<ArrowRightIcon size={16} />
111+
</Button>
112+
</div>
113+
</div>
114+
);
115+
}

src/components/ui/NotificationCountBadge.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ export const NotificationCountBadge = ({ count = 0 }: Props) => {
88
return count ? (
99
<div
1010
className={cn(
11-
count <= 9 ? "w-5 h-5" : "py-2.5 px-2",
12-
"relative bg-netbird flex items-center justify-center rounded-full text-white !leading-[0] text-xs font-semibold",
11+
count <= 9 ? "w-4 h-4" : "py-2 px-1.5",
12+
"relative bg-netbird flex items-center justify-center rounded-full text-white !leading-[0] text-[0.6rem] font-semibold",
1313
)}
1414
>
1515
<span className="animate-ping absolute left-0 inline-flex h-full w-full rounded-full bg-netbird opacity-20"></span>
16-
{count || 0}
16+
<span className={"relative -left-[0.5px]"}>{count || 0}</span>
1717
</div>
1818
) : null;
1919
};

src/contexts/UsersProvider.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,24 @@ const UserProfileProvider = ({ children }: Props) => {
6262
}
6363
}, [user, error, users, isLoading, isAllUsersLoading]);
6464

65+
6566
const data = useMemo(() => {
6667
return {
6768
loggedInUser,
6869
};
6970
}, [loggedInUser]);
7071

71-
return !isLoading && loggedInUser ? (
72+
// Show loading only when we're still loading and don't have user data
73+
if (isLoading || !loggedInUser) {
74+
return <FullScreenLoading />;
75+
}
76+
77+
// For blocked or pending approval users, we still need to provide the context
78+
// so they can access their user data on the blocked page
79+
return (
7280
<UserProfileContext.Provider value={data}>
7381
<PermissionsProvider user={loggedInUser}>{children}</PermissionsProvider>
7482
</UserProfileContext.Provider>
75-
) : (
76-
<FullScreenLoading />
7783
);
7884
};
7985

src/interfaces/Account.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface Account {
77
settings: {
88
extra: {
99
peer_approval_enabled: boolean;
10+
user_approval_required: boolean;
1011
};
1112
peer_login_expiration_enabled: boolean;
1213
peer_login_expiration: number;

src/interfaces/User.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface User {
1010
is_current?: boolean;
1111
is_service_user?: boolean;
1212
is_blocked?: boolean;
13+
pending_approval?: boolean;
1314
last_login?: Date;
1415
permissions: Permissions;
1516
}

src/modules/activity/ActivityDescription.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,22 @@ export default function ActivityDescription({ event }: Props) {
253253
</div>
254254
);
255255

256+
if (event.activity_code == "user.approve")
257+
return (
258+
<div className={"inline"}>
259+
User <Value>{event.meta.username}</Value>{" "}
260+
<Value>{event.meta.email}</Value> was approved
261+
</div>
262+
);
263+
264+
if (event.activity_code == "user.reject")
265+
return (
266+
<div className={"inline"}>
267+
User <Value>{event.meta.username}</Value>{" "}
268+
<Value>{event.meta.email}</Value> was rejected
269+
</div>
270+
);
271+
256272
/**
257273
* Service User
258274
*/

src/modules/activity/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const ACTION_COLOR_MAPPING: Record<string, ActionStatus> = {
1919
delete: ActionStatus.ERROR,
2020
revoke: ActionStatus.ERROR,
2121
block: ActionStatus.ERROR,
22+
reject: ActionStatus.ERROR,
2223

2324
// Warning actions
2425
overuse: ActionStatus.WARNING,

src/modules/settings/AuthenticationTab.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
CalendarClock,
2424
ExternalLinkIcon,
2525
ShieldIcon,
26+
ShieldUserIcon,
2627
TimerResetIcon,
2728
} from "lucide-react";
2829
import React, { useState } from "react";
@@ -52,6 +53,19 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
5253
}
5354
});
5455

56+
/**
57+
* User approval required
58+
*/
59+
const [userApprovalRequired, setUserApprovalRequired] = useState<boolean>(
60+
() => {
61+
try {
62+
return account?.settings?.extra?.user_approval_required || false;
63+
} catch (error) {
64+
return false;
65+
}
66+
},
67+
);
68+
5569
// Peer Expiration
5670
const [
5771
loginExpiration,
@@ -86,6 +100,7 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
86100

87101
const { hasChanges, updateRef } = useHasChanges([
88102
peerApproval,
103+
userApprovalRequired,
89104
loginExpiration,
90105
expiresIn,
91106
expireInterval,
@@ -118,13 +133,15 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
118133
extra: {
119134
...account.settings?.extra,
120135
peer_approval_enabled: peerApproval,
136+
user_approval_required: userApprovalRequired,
121137
},
122138
},
123139
} as Account)
124140
.then(() => {
125141
mutate("/accounts");
126142
updateRef([
127143
peerApproval,
144+
userApprovalRequired,
128145
loginExpiration,
129146
expiresIn,
130147
expireInterval,
@@ -181,6 +198,27 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
181198
</div>
182199

183200
<div className={"flex flex-col gap-6 w-full mt-8 mb-3"}>
201+
<div className={"flex flex-col"}>
202+
<FancyToggleSwitch
203+
value={userApprovalRequired}
204+
onChange={setUserApprovalRequired}
205+
dataCy={"user-approval-required"}
206+
label={
207+
<>
208+
<ShieldUserIcon size={15} />
209+
User Approval Required
210+
</>
211+
}
212+
helpText={
213+
<>
214+
Require manual approval for new users joining via <br />
215+
domain matching. Users will be blocked until approved.
216+
</>
217+
}
218+
disabled={!permission.settings.update}
219+
/>
220+
</div>
221+
184222
<div className={"flex flex-col"}>
185223
<FancyToggleSwitch
186224
value={loginExpiration}

0 commit comments

Comments
 (0)