feat: allow admins to delete users

This commit is contained in:
Sheen Capadngan
2024-07-08 15:04:08 +08:00
parent 5eba61b647
commit 6e15979672
8 changed files with 247 additions and 6 deletions
@@ -4,6 +4,7 @@ import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
@@ -91,6 +92,75 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
} }
}); });
server.route({
method: "GET",
url: "/list",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
users: UsersSchema.pick({
username: true,
firstName: true,
lastName: true,
email: true,
id: true
}).array()
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async () => {
const users = await server.services.user.listUsers();
return {
users
};
}
});
server.route({
method: "DELETE",
url: "/:userId",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
userId: z.string()
}),
response: {
200: z.object({
users: UsersSchema.pick({
username: true,
firstName: true,
lastName: true,
email: true,
id: true
})
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async (req) => {
const users = await server.services.user.deleteUser(req.params.userId);
return {
users
};
}
});
server.route({ server.route({
method: "GET", method: "GET",
url: "/me/project-favorites", url: "/me/project-favorites",
+1 -1
View File
@@ -297,7 +297,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const user = await server.services.user.deleteMe(req.permission.id); const user = await server.services.user.deleteUser(req.permission.id);
return { user }; return { user };
} }
}); });
+8 -3
View File
@@ -201,7 +201,7 @@ export const userServiceFactory = ({
return user; return user;
}; };
const deleteMe = async (userId: string) => { const deleteUser = async (userId: string) => {
const user = await userDAL.deleteById(userId); const user = await userDAL.deleteById(userId);
return user; return user;
}; };
@@ -295,19 +295,24 @@ export const userServiceFactory = ({
return updatedOrgMembership.projectFavorites; return updatedOrgMembership.projectFavorites;
}; };
const listUsers = async () => {
return userDAL.find({});
};
return { return {
sendEmailVerificationCode, sendEmailVerificationCode,
verifyEmailVerificationCode, verifyEmailVerificationCode,
toggleUserMfa, toggleUserMfa,
updateUserName, updateUserName,
updateAuthMethods, updateAuthMethods,
deleteMe, deleteUser,
getMe, getMe,
createUserAction, createUserAction,
getUserAction, getUserAction,
unlockUser, unlockUser,
getUserPrivateKey, getUserPrivateKey,
getUserProjectFavorites, getUserProjectFavorites,
updateUserProjectFavorites updateUserProjectFavorites,
listUsers
}; };
}; };
+2 -1
View File
@@ -1,6 +1,7 @@
export { export {
useAddUserToWsE2EE, useAddUserToWsE2EE,
useAddUserToWsNonE2EE, useAddUserToWsNonE2EE,
useDeleteUser,
useSendEmailVerificationCode, useSendEmailVerificationCode,
useVerifyEmailVerificationCode useVerifyEmailVerificationCode
} from "./mutation"; } from "./mutation";
@@ -10,7 +11,6 @@ export {
useCreateAPIKey, useCreateAPIKey,
useDeleteAPIKey, useDeleteAPIKey,
useDeleteOrgMembership, useDeleteOrgMembership,
useDeleteUser,
useGetMyAPIKeys, useGetMyAPIKeys,
useGetMyAPIKeysV2, useGetMyAPIKeysV2,
useGetMyIp, useGetMyIp,
@@ -19,6 +19,7 @@ export {
useGetOrgUsers, useGetOrgUsers,
useGetUser, useGetUser,
useGetUserAction, useGetUserAction,
useListUsers,
useLogoutUser, useLogoutUser,
useRegisterUserAction, useRegisterUserAction,
useRevokeMySessions, useRevokeMySessions,
+14
View File
@@ -112,3 +112,17 @@ export const useUpdateUserProjectFavorites = () => {
} }
}); });
}; };
export const useDeleteUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (userId: string) => {
await apiRequest.delete(`/api/v1/user/${userId}`);
return {};
},
onSuccess: () => {
queryClient.invalidateQueries(userKeys.listUsers);
}
});
};
+9
View File
@@ -28,6 +28,7 @@ export const userKeys = {
myAPIKeys: ["api-keys"] as const, myAPIKeys: ["api-keys"] as const,
myAPIKeysV2: ["api-keys-v2"] as const, myAPIKeysV2: ["api-keys-v2"] as const,
mySessions: ["sessions"] as const, mySessions: ["sessions"] as const,
listUsers: ["user-list"] as const,
myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const
}; };
@@ -40,6 +41,14 @@ export const fetchUserDetails = async () => {
export const useGetUser = () => useQuery(userKeys.getUser, fetchUserDetails); export const useGetUser = () => useQuery(userKeys.getUser, fetchUserDetails);
export const fetchUsersList = async () => {
const { data } = await apiRequest.get<{ users: User[] }>("/api/v1/user/list");
return data.users;
};
export const useListUsers = () => useQuery(userKeys.listUsers, fetchUsersList);
export const useDeleteUser = () => { export const useDeleteUser = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -26,11 +26,13 @@ import { useGetOrganizations, useUpdateServerConfig } from "@app/hooks/api";
import { AuthPanel } from "./AuthPanel"; import { AuthPanel } from "./AuthPanel";
import { RateLimitPanel } from "./RateLimitPanel"; import { RateLimitPanel } from "./RateLimitPanel";
import { UserPanel } from "./UserPanel";
enum TabSections { enum TabSections {
Settings = "settings", Settings = "settings",
Auth = "auth", Auth = "auth",
RateLimit = "rate-limit" RateLimit = "rate-limit",
Users = "users"
} }
enum SignUpModes { enum SignUpModes {
@@ -135,6 +137,7 @@ export const AdminDashboardPage = () => {
<Tab value={TabSections.Settings}>General</Tab> <Tab value={TabSections.Settings}>General</Tab>
<Tab value={TabSections.Auth}>Authentication</Tab> <Tab value={TabSections.Auth}>Authentication</Tab>
<Tab value={TabSections.RateLimit}>Rate Limit</Tab> <Tab value={TabSections.RateLimit}>Rate Limit</Tab>
<Tab value={TabSections.Users}>Users</Tab>
</div> </div>
</TabList> </TabList>
<TabPanel value={TabSections.Settings}> <TabPanel value={TabSections.Settings}>
@@ -320,6 +323,9 @@ export const AdminDashboardPage = () => {
<TabPanel value={TabSections.RateLimit}> <TabPanel value={TabSections.RateLimit}>
<RateLimitPanel /> <RateLimitPanel />
</TabPanel> </TabPanel>
<TabPanel value={TabSections.Users}>
<UserPanel />
</TabPanel>
</Tabs> </Tabs>
</div> </div>
)} )}
@@ -0,0 +1,136 @@
import { useMemo, useState } from "react";
import { faMagnifyingGlass, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
DeleteActionModal,
EmptyState,
IconButton,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useUser } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteUser, useListUsers } from "@app/hooks/api";
export const UserPanel = () => {
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeUser"
] as const);
const { user } = useUser();
const userId = user?.id || "";
const { data: users, isLoading } = useListUsers();
const { mutateAsync: deleteUser } = useDeleteUser();
const filterdUsers = useMemo(
() =>
users?.filter(
({ firstName, lastName, username, email }) =>
firstName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
lastName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
username?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
email?.toLowerCase().includes(searchMemberFilter.toLowerCase())
),
[users, searchMemberFilter]
);
const handleRemoveUser = async () => {
const { id } = popUp?.removeUser?.data as { id: string; username: string };
try {
await deleteUser(id);
createNotification({
type: "success",
text: "Successfully deleted user"
});
} catch (err) {
createNotification({
type: "error",
text: "Error deleting user"
});
}
handlePopUpClose("removeUser");
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<p className="text-xl font-semibold text-mineshaft-100">Users</p>
</div>
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<div className="mt-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Username</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="users" />}
{!isLoading &&
filterdUsers?.map(({ username, email, firstName, lastName, id }) => {
const name = firstName || lastName ? `${firstName} ${lastName}` : "-";
return (
<Tr key={`user-${id}`} className="w-full">
<Td>{name}</Td>
<Td>{email}</Td>
<Td>
{userId !== id && (
<div className="flex items-center space-x-2">
<IconButton
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={userId === id}
onClick={() => handlePopUpOpen("removeUser", { username, id })}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
)}
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && filterdUsers?.length === 0 && (
<EmptyState title="No users found" icon={faUsers} />
)}
</TableContainer>
</div>
<DeleteActionModal
isOpen={popUp.removeUser.isOpen}
deleteKey="remove"
title={`Are you sure you want to delete User with username ${
(popUp?.removeUser?.data as { id: string; username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeUser", isOpen)}
onDeleteApproved={handleRemoveUser}
/>
</div>
);
};