Skip to content

Commit 499c554

Browse files
committed
Add user approval feature
1 parent 5d4e491 commit 499c554

File tree

10 files changed

+295
-27
lines changed

10 files changed

+295
-27
lines changed

src/app/error/page.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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 = error?.code === 403 && error?.message?.toLowerCase().includes("blocked");
55+
const isPendingApproval = error?.code === 403 && error?.message?.toLowerCase().includes("pending");
56+
57+
const getTitle = () => {
58+
if (isBlockedUser) return "User Account Blocked";
59+
if (isPendingApproval) return "User Approval Pending";
60+
return "Access Error";
61+
};
62+
63+
const getDescription = () => {
64+
if (isBlockedUser) {
65+
return "Your account has been blocked by an administrator. You cannot access the dashboard at this time.";
66+
}
67+
if (isPendingApproval) {
68+
return "Your account is pending approval from an administrator. Please wait for approval before accessing the dashboard.";
69+
}
70+
return "An error occurred while trying to access the dashboard. Please try again or contact your administrator.";
71+
};
72+
73+
return (
74+
<div className="flex items-center justify-center flex-col h-screen max-w-lg mx-auto">
75+
<div className="bg-nb-gray-930 mb-3 border border-nb-gray-900 h-12 w-12 rounded-md flex items-center justify-center">
76+
<NetBirdIcon size={23} />
77+
</div>
78+
79+
<h1 className="text-center mt-2">{getTitle()}</h1>
80+
81+
<Paragraph className="text-center mt-2 block">
82+
{getDescription()}
83+
</Paragraph>
84+
85+
{error && (
86+
<Paragraph className="text-center mt-4 block">
87+
Error: <span className="inline capitalize">{error.message}</span>
88+
{error.code && <span className="block text-sm text-nb-gray-400 mt-1">Code: {error.code}</span>}
89+
</Paragraph>
90+
)}
91+
92+
<Paragraph className="text-center mt-2 text-sm">
93+
If you believe this is an error, please contact your administrator.
94+
</Paragraph>
95+
96+
<div className="mt-5 space-y-3">
97+
{!isBlockedUser && !isPendingApproval && (
98+
<Button
99+
variant="default-outline"
100+
size="sm"
101+
onClick={handleRetry}
102+
>
103+
<RefreshCw size={16} className="mr-2" />
104+
Try Again
105+
</Button>
106+
)}
107+
108+
<Button
109+
variant="primary"
110+
size="sm"
111+
onClick={handleLogout}
112+
>
113+
{isBlockedUser || isPendingApproval ? "Sign Out" : "Logout"}
114+
<ArrowRightIcon size={16} />
115+
</Button>
116+
</div>
117+
</div>
118+
);
119+
}

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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
5252
}
5353
});
5454

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

8798
const { hasChanges, updateRef } = useHasChanges([
8899
peerApproval,
100+
userApprovalRequired,
89101
loginExpiration,
90102
expiresIn,
91103
expireInterval,
@@ -118,13 +130,15 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
118130
extra: {
119131
...account.settings?.extra,
120132
peer_approval_enabled: peerApproval,
133+
user_approval_required: userApprovalRequired,
121134
},
122135
},
123136
} as Account)
124137
.then(() => {
125138
mutate("/accounts");
126139
updateRef([
127140
peerApproval,
141+
userApprovalRequired,
128142
loginExpiration,
129143
expiresIn,
130144
expireInterval,
@@ -181,6 +195,27 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
181195
</div>
182196

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

src/modules/users/table-cells/UserActionCell.tsx

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Button from "@components/Button";
22
import { notify } from "@components/Notification";
33
import { useApiCall } from "@utils/api";
44
import { isNetBirdHosted } from "@utils/netbird";
5-
import { Trash2 } from "lucide-react";
5+
import { CheckCircle, Trash2, XCircle } from "lucide-react";
66
import * as React from "react";
77
import { useMemo } from "react";
88
import { useSWRConfig } from "swr";
@@ -36,6 +36,40 @@ export default function UserActionCell({
3636
});
3737
};
3838

39+
const approveUser = async () => {
40+
const name = user.name || "User";
41+
notify({
42+
title: `'${name}' approved`,
43+
description: "User was successfully approved.",
44+
promise: userRequest.post("", `/${user.id}/approve`).then(() => {
45+
mutate(`/users?service_user=${serviceUser}`);
46+
}),
47+
loadingMessage: "Approving the user...",
48+
});
49+
};
50+
51+
const rejectUser = async () => {
52+
const name = user.name || "User";
53+
const choice = await confirm({
54+
title: `Reject '${name}'?`,
55+
description:
56+
"Rejecting this user will remove them from the account permanently. This action cannot be undone.",
57+
confirmText: "Reject",
58+
cancelText: "Cancel",
59+
type: "danger",
60+
});
61+
if (!choice) return;
62+
63+
notify({
64+
title: `'${name}' rejected`,
65+
description: "User was successfully rejected and removed.",
66+
promise: userRequest.del("", `/${user.id}/reject`).then(() => {
67+
mutate(`/users?service_user=${serviceUser}`);
68+
}),
69+
loadingMessage: "Rejecting the user...",
70+
});
71+
};
72+
3973
const openConfirm = async () => {
4074
const name = user.name || "User";
4175
const choice = await confirm({
@@ -55,21 +89,50 @@ export default function UserActionCell({
5589
return user.is_current;
5690
}, [permission.users.delete, user.is_current]);
5791

92+
const isPendingApproval = user.pending_approval;
93+
const canManageUsers = permission.users.update;
94+
5895
return (
59-
<div className={"flex justify-end pr-4 items-center gap-4"}>
60-
{!serviceUser && isNetBirdHosted() && (
96+
<div className={"flex justify-end pr-4 items-center gap-2"}>
97+
{!serviceUser && isNetBirdHosted() && !isPendingApproval && (
6198
<UserResendInviteButton user={user} />
6299
)}
63-
<Button
64-
variant={"danger-outline"}
65-
size={"sm"}
66-
onClick={openConfirm}
67-
data-cy={"delete-user"}
68-
disabled={disabled}
69-
>
70-
<Trash2 size={16} />
71-
Delete
72-
</Button>
100+
101+
{isPendingApproval && canManageUsers && (
102+
<>
103+
<Button
104+
variant={"outline"}
105+
size={"sm"}
106+
onClick={approveUser}
107+
data-cy={"approve-user"}
108+
>
109+
<CheckCircle size={16} />
110+
Approve
111+
</Button>
112+
<Button
113+
variant={"danger-outline"}
114+
size={"sm"}
115+
onClick={rejectUser}
116+
data-cy={"reject-user"}
117+
>
118+
<XCircle size={16} />
119+
Reject
120+
</Button>
121+
</>
122+
)}
123+
124+
{!isPendingApproval && (
125+
<Button
126+
variant={"danger-outline"}
127+
size={"sm"}
128+
onClick={openConfirm}
129+
data-cy={"delete-user"}
130+
disabled={disabled}
131+
>
132+
<Trash2 size={16} />
133+
Delete
134+
</Button>
135+
)}
73136
</div>
74137
);
75138
}

0 commit comments

Comments
 (0)