From 6e15979672732e950bd044a8db969241c607b152 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 8 Jul 2024 15:04:08 +0800 Subject: [PATCH 1/7] feat: allow admins to delete users --- backend/src/server/routes/v1/user-router.ts | 70 +++++++++ backend/src/server/routes/v2/user-router.ts | 2 +- backend/src/services/user/user-service.ts | 11 +- frontend/src/hooks/api/users/index.tsx | 3 +- frontend/src/hooks/api/users/mutation.tsx | 14 ++ frontend/src/hooks/api/users/queries.tsx | 9 ++ .../admin/DashboardPage/DashboardPage.tsx | 8 +- .../views/admin/DashboardPage/UserPanel.tsx | 136 ++++++++++++++++++ 8 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 frontend/src/views/admin/DashboardPage/UserPanel.tsx diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index d3c0db242..3510179c9 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -4,6 +4,7 @@ import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; 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 { 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({ method: "GET", url: "/me/project-favorites", diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 21dd32021..01c7eda6d 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -297,7 +297,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), 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 }; } }); diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 5f0106604..de10baf8f 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -201,7 +201,7 @@ export const userServiceFactory = ({ return user; }; - const deleteMe = async (userId: string) => { + const deleteUser = async (userId: string) => { const user = await userDAL.deleteById(userId); return user; }; @@ -295,19 +295,24 @@ export const userServiceFactory = ({ return updatedOrgMembership.projectFavorites; }; + const listUsers = async () => { + return userDAL.find({}); + }; + return { sendEmailVerificationCode, verifyEmailVerificationCode, toggleUserMfa, updateUserName, updateAuthMethods, - deleteMe, + deleteUser, getMe, createUserAction, getUserAction, unlockUser, getUserPrivateKey, getUserProjectFavorites, - updateUserProjectFavorites + updateUserProjectFavorites, + listUsers }; }; diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index a8ad89f4c..87cf7c78f 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,6 +1,7 @@ export { useAddUserToWsE2EE, useAddUserToWsNonE2EE, + useDeleteUser, useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "./mutation"; @@ -10,7 +11,6 @@ export { useCreateAPIKey, useDeleteAPIKey, useDeleteOrgMembership, - useDeleteUser, useGetMyAPIKeys, useGetMyAPIKeysV2, useGetMyIp, @@ -19,6 +19,7 @@ export { useGetOrgUsers, useGetUser, useGetUserAction, + useListUsers, useLogoutUser, useRegisterUserAction, useRevokeMySessions, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 26e932ac6..9aed160da 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -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); + } + }); +}; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 1a49e8d7c..962961bc2 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -28,6 +28,7 @@ export const userKeys = { myAPIKeys: ["api-keys"] as const, myAPIKeysV2: ["api-keys-v2"] as const, mySessions: ["sessions"] as const, + listUsers: ["user-list"] 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 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 = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index 42d3cdc1b..715bb2e47 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -26,11 +26,13 @@ import { useGetOrganizations, useUpdateServerConfig } from "@app/hooks/api"; import { AuthPanel } from "./AuthPanel"; import { RateLimitPanel } from "./RateLimitPanel"; +import { UserPanel } from "./UserPanel"; enum TabSections { Settings = "settings", Auth = "auth", - RateLimit = "rate-limit" + RateLimit = "rate-limit", + Users = "users" } enum SignUpModes { @@ -135,6 +137,7 @@ export const AdminDashboardPage = () => { General Authentication Rate Limit + Users @@ -320,6 +323,9 @@ export const AdminDashboardPage = () => { + + + )} diff --git a/frontend/src/views/admin/DashboardPage/UserPanel.tsx b/frontend/src/views/admin/DashboardPage/UserPanel.tsx new file mode 100644 index 000000000..b79eb4981 --- /dev/null +++ b/frontend/src/views/admin/DashboardPage/UserPanel.tsx @@ -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 ( +
+
+

Users

+
+ setSearchMemberFilter(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + +
+ + + + + + + + + + {isLoading && } + {!isLoading && + filterdUsers?.map(({ username, email, firstName, lastName, id }) => { + const name = firstName || lastName ? `${firstName} ${lastName}` : "-"; + + return ( + + + + + + ); + })} + +
NameUsername +
{name}{email} + {userId !== id && ( +
+ handlePopUpOpen("removeUser", { username, id })} + > + + +
+ )} +
+ {!isLoading && filterdUsers?.length === 0 && ( + + )} +
+
+ handlePopUpToggle("removeUser", isOpen)} + onDeleteApproved={handleRemoveUser} + /> +
+ ); +}; From ccf0c3cd35b22281dbedb26396f265c878f60182 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 8 Jul 2024 15:09:32 +0800 Subject: [PATCH 2/7] misc: modified member to user --- .../views/admin/DashboardPage/UserPanel.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/src/views/admin/DashboardPage/UserPanel.tsx b/frontend/src/views/admin/DashboardPage/UserPanel.tsx index b79eb4981..e8ab6dfb4 100644 --- a/frontend/src/views/admin/DashboardPage/UserPanel.tsx +++ b/frontend/src/views/admin/DashboardPage/UserPanel.tsx @@ -22,7 +22,7 @@ import { usePopUp } from "@app/hooks"; import { useDeleteUser, useListUsers } from "@app/hooks/api"; export const UserPanel = () => { - const [searchMemberFilter, setSearchMemberFilter] = useState(""); + const [searchUserFilter, setSearchUserFilter] = useState(""); const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "removeUser" ] as const); @@ -36,12 +36,12 @@ export const UserPanel = () => { () => 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()) + firstName?.toLowerCase().includes(searchUserFilter.toLowerCase()) || + lastName?.toLowerCase().includes(searchUserFilter.toLowerCase()) || + username?.toLowerCase().includes(searchUserFilter.toLowerCase()) || + email?.toLowerCase().includes(searchUserFilter.toLowerCase()) ), - [users, searchMemberFilter] + [users, searchUserFilter] ); const handleRemoveUser = async () => { @@ -69,10 +69,10 @@ export const UserPanel = () => {

Users

setSearchMemberFilter(e.target.value)} + value={searchUserFilter} + onChange={(e) => setSearchUserFilter(e.target.value)} leftIcon={} - placeholder="Search members..." + placeholder="Search users..." />
From f3a8fda254221c24d95a44d22405f9429e85229c Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 8 Jul 2024 15:16:10 +0800 Subject: [PATCH 3/7] misc: resolved conflict with existing method --- frontend/src/hooks/api/users/index.tsx | 1 + frontend/src/hooks/api/users/queries.tsx | 2 +- .../DeleteAccountSection/DeleteAccountSection.tsx | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 87cf7c78f..b7e8d6be3 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -10,6 +10,7 @@ export { useAddUserToOrg, useCreateAPIKey, useDeleteAPIKey, + useDeleteMe, useDeleteOrgMembership, useGetMyAPIKeys, useGetMyAPIKeysV2, diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 962961bc2..bee58027c 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -49,7 +49,7 @@ export const fetchUsersList = async () => { export const useListUsers = () => useQuery(userKeys.listUsers, fetchUsersList); -export const useDeleteUser = () => { +export const useDeleteMe = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/views/Settings/PersonalSettingsPage/DeleteAccountSection/DeleteAccountSection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/DeleteAccountSection/DeleteAccountSection.tsx index 49908eabc..fab1057ba 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/DeleteAccountSection/DeleteAccountSection.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/DeleteAccountSection/DeleteAccountSection.tsx @@ -2,17 +2,17 @@ import { useRouter } from "next/router"; import { createNotification } from "@app/components/notifications"; import { Button, DeleteActionModal } from "@app/components/v2"; -import { useDeleteUser } from "@app/hooks/api"; +import { useDeleteMe } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; export const DeleteAccountSection = () => { const router = useRouter(); - + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "deleteAccount" ] as const); - const { mutateAsync: deleteUserMutateAsync, isLoading } = useDeleteUser(); + const { mutateAsync: deleteUserMutateAsync, isLoading } = useDeleteMe(); const handleDeleteAccountSubmit = async () => { try { From c1b242db67685d82b161281af42b6d3a9ad572ee Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Jul 2024 00:34:07 +0800 Subject: [PATCH 4/7] misc: added pagination and moved to admin route --- backend/src/server/routes/v1/admin-router.ts | 76 +++++++++ backend/src/server/routes/v1/user-router.ts | 70 -------- .../super-admin/super-admin-service.ts | 19 ++- .../services/super-admin/super-admin-types.ts | 6 + backend/src/services/user/user-dal.ts | 31 +++- backend/src/services/user/user-service.ts | 7 +- frontend/src/hooks/api/admin/index.ts | 4 +- frontend/src/hooks/api/admin/mutation.ts | 18 +- frontend/src/hooks/api/admin/queries.ts | 33 +++- frontend/src/hooks/api/admin/types.ts | 5 + frontend/src/hooks/api/users/index.tsx | 2 - frontend/src/hooks/api/users/mutation.tsx | 14 -- frontend/src/hooks/api/users/queries.tsx | 8 - .../views/admin/DashboardPage/UserPanel.tsx | 160 ++++++++++-------- 14 files changed, 276 insertions(+), 177 deletions(-) diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 73d6cc7e7..0169b15c0 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -83,6 +83,82 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/user-management/users", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + searchTerm: z.string().default(""), + offset: z.coerce.number().default(0), + limit: z.coerce.number().default(20) + }), + 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 (req) => { + const users = await server.services.superAdmin.getUsers({ + ...req.query + }); + + return { + users + }; + } + }); + + server.route({ + method: "DELETE", + url: "/user-management/users/: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.superAdmin.deleteUser(req.params.userId); + + return { + users + }; + } + }); + server.route({ method: "POST", url: "/signup", diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index 3510179c9..d3c0db242 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -4,7 +4,6 @@ import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; 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 { AuthMode } from "@app/services/auth/auth-type"; @@ -92,75 +91,6 @@ 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({ method: "GET", url: "/me/project-favorites", diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 70330c4ef..f5987cb15 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -12,7 +12,7 @@ import { AuthMethod } from "../auth/auth-type"; import { TOrgServiceFactory } from "../org/org-service"; import { TUserDALFactory } from "../user/user-dal"; import { TSuperAdminDALFactory } from "./super-admin-dal"; -import { LoginMethod, TAdminSignUpDTO } from "./super-admin-types"; +import { LoginMethod, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { serverCfgDAL: TSuperAdminDALFactory; @@ -209,9 +209,24 @@ export const superAdminServiceFactory = ({ return { token, user: userInfo, organization }; }; + const getUsers = ({ offset, limit, searchTerm }: TAdminGetUsersDTO) => { + return userDAL.getUsersByFilter({ + limit, + offset, + searchTerm + }); + }; + + const deleteUser = async (userId: string) => { + const user = await userDAL.deleteById(userId); + return user; + }; + return { initServerCfg, updateServerCfg, - adminSignUp + adminSignUp, + getUsers, + deleteUser }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index f622c8f17..2d10941b4 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -16,6 +16,12 @@ export type TAdminSignUpDTO = { userAgent: string; }; +export type TAdminGetUsersDTO = { + offset: number; + limit: number; + searchTerm: string; +}; + export enum LoginMethod { EMAIL = "email", GOOGLE = "google", diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index e50d3cc68..1bca2cd43 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -10,7 +10,7 @@ import { TUserEncryptionKeysUpdate } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TUserDALFactory = ReturnType; @@ -18,6 +18,32 @@ export const userDALFactory = (db: TDbClient) => { const userOrm = ormify(db, TableName.Users); const findUserByUsername = async (username: string, tx?: Knex) => userOrm.findOne({ username }, tx); + const getUsersByFilter = async ({ + limit, + offset, + searchTerm + }: { + limit: number; + offset: number; + searchTerm: string; + }) => { + try { + let query = db.replicaNode()(TableName.Users).where("isGhost", "=", false); + if (searchTerm) { + query = query.where((qb) => { + void qb + .whereILike("email", `%${searchTerm}%`) + .orWhereILike("firstName", `%${searchTerm}%`) + .orWhereILike("lastName", `%${searchTerm}%`) + .orWhereLike("username", `%${searchTerm}%`); + }); + } + return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Users)); + } catch (error) { + throw new DatabaseError({ error, name: "Get users by filter" }); + } + }; + // USER ENCRYPTION FUNCTIONS // ------------------------- const findUserEncKeyByUsername = async ({ username }: { username: string }) => { @@ -159,6 +185,7 @@ export const userDALFactory = (db: TDbClient) => { upsertUserEncryptionKey, createUserEncryption, findOneUserAction, - createUserAction + createUserAction, + getUsersByFilter }; }; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index de10baf8f..f0b043279 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -295,10 +295,6 @@ export const userServiceFactory = ({ return updatedOrgMembership.projectFavorites; }; - const listUsers = async () => { - return userDAL.find({}); - }; - return { sendEmailVerificationCode, verifyEmailVerificationCode, @@ -312,7 +308,6 @@ export const userServiceFactory = ({ unlockUser, getUserPrivateKey, getUserProjectFavorites, - updateUserProjectFavorites, - listUsers + updateUserProjectFavorites }; }; diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index e1c4301d4..516d98435 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -1,2 +1,2 @@ -export { useCreateAdminUser, useUpdateServerConfig } from "./mutation"; -export { useGetServerConfig } from "./queries"; +export { useCreateAdminUser, useDeleteUser, useUpdateServerConfig } from "./mutation"; +export { useAdminGetUsers, useGetServerConfig } from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index 6d25944ef..4f5a6f81e 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -4,7 +4,7 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { User } from "../users/types"; -import { adminQueryKeys } from "./queries"; +import { adminQueryKeys, adminStandaloneKeys } from "./queries"; import { TCreateAdminUserDTO, TServerConfig } from "./types"; export const useCreateAdminUser = () => { @@ -43,3 +43,19 @@ export const useUpdateServerConfig = () => { } }); }; + +export const useDeleteUser = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (userId: string) => { + await apiRequest.delete(`/api/v1/admin/user-management/users/${userId}`); + + return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [adminStandaloneKeys.getUsers] + }); + } + }); +}; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index f64c8bfa9..91368fb9e 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -1,11 +1,17 @@ -import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TServerConfig } from "./types"; +import { User } from "../types"; +import { AdminGetUsersFilters, TServerConfig } from "./types"; + +export const adminStandaloneKeys = { + getUsers: "get-users" +}; export const adminQueryKeys = { - serverConfig: () => ["server-config"] as const + serverConfig: () => ["server-config"] as const, + getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const }; const fetchServerConfig = async () => { @@ -32,3 +38,24 @@ export const useGetServerConfig = ({ ...options, enabled: options?.enabled ?? true }); + +export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { + return useInfiniteQuery({ + queryKey: adminQueryKeys.getUsers(filters), + queryFn: async ({ pageParam }) => { + const { data } = await apiRequest.get<{ users: User[] }>( + "/api/v1/admin/user-management/users", + { + params: { + ...filters, + offset: pageParam + } + } + ); + + return data.users; + }, + getNextPageParam: (lastPage, pages) => + lastPage.length !== 0 ? pages.length * filters.limit : undefined + }); +}; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 4d5add1e0..bfa2e3e36 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -37,3 +37,8 @@ export type TCreateAdminUserDTO = { verifier: string; salt: string; }; + +export type AdminGetUsersFilters = { + limit: number; + searchTerm: string; +}; diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index b7e8d6be3..521c36468 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,7 +1,6 @@ export { useAddUserToWsE2EE, useAddUserToWsNonE2EE, - useDeleteUser, useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "./mutation"; @@ -20,7 +19,6 @@ export { useGetOrgUsers, useGetUser, useGetUserAction, - useListUsers, useLogoutUser, useRegisterUserAction, useRevokeMySessions, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 9aed160da..26e932ac6 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -112,17 +112,3 @@ 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); - } - }); -}; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index bee58027c..5ca47fc63 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -41,14 +41,6 @@ export const fetchUserDetails = async () => { 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 useDeleteMe = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/views/admin/DashboardPage/UserPanel.tsx b/frontend/src/views/admin/DashboardPage/UserPanel.tsx index e8ab6dfb4..16ebceec1 100644 --- a/frontend/src/views/admin/DashboardPage/UserPanel.tsx +++ b/frontend/src/views/admin/DashboardPage/UserPanel.tsx @@ -1,9 +1,10 @@ -import { useMemo, useState } from "react"; +import { 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 { + Button, DeleteActionModal, EmptyState, IconButton, @@ -18,31 +19,103 @@ import { Tr } from "@app/components/v2"; import { useUser } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { useDeleteUser, useListUsers } from "@app/hooks/api"; +import { useDebounce, usePopUp } from "@app/hooks"; +import { useAdminGetUsers, useDeleteUser } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const UserPanelTable = ({ + searchUserFilter, + handlePopUpOpen +}: { + searchUserFilter: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeUser"]>, + data: { + username: string; + id: string; + } + ) => void; +}) => { + const { user } = useUser(); + const userId = user?.id || ""; + const debounedSearchTerm = useDebounce(searchUserFilter, 500); + + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({ + limit: 20, + searchTerm: debounedSearchTerm + }); + + const isEmpty = !isLoading && !data?.pages?.[0].length; + return ( +
+ + + + + + + + + + {isLoading && } + {!isLoading && + data?.pages?.map((users) => + users.map(({ username, email, firstName, lastName, id }) => { + const name = firstName || lastName ? `${firstName} ${lastName}` : "-"; + + return ( + + + + + + ); + }) + )} + +
NameUsername +
{name}{email} + {userId !== id && ( +
+ handlePopUpOpen("removeUser", { username, id })} + > + + +
+ )} +
+ {!isLoading && isEmpty && } +
+ {!isEmpty && ( + + )} +
+ ); +}; export const UserPanel = () => { - const [searchUserFilter, setSearchUserFilter] = 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 [searchUserFilter, setSearchUserFilter] = useState(""); - const filterdUsers = useMemo( - () => - users?.filter( - ({ firstName, lastName, username, email }) => - firstName?.toLowerCase().includes(searchUserFilter.toLowerCase()) || - lastName?.toLowerCase().includes(searchUserFilter.toLowerCase()) || - username?.toLowerCase().includes(searchUserFilter.toLowerCase()) || - email?.toLowerCase().includes(searchUserFilter.toLowerCase()) - ), - [users, searchUserFilter] - ); + const { mutateAsync: deleteUser } = useDeleteUser(); const handleRemoveUser = async () => { const { id } = popUp?.removeUser?.data as { id: string; username: string }; @@ -74,54 +147,7 @@ export const UserPanel = () => { leftIcon={} placeholder="Search users..." /> - -
- - - - - - - - - - {isLoading && } - {!isLoading && - filterdUsers?.map(({ username, email, firstName, lastName, id }) => { - const name = firstName || lastName ? `${firstName} ${lastName}` : "-"; - - return ( - - - - - - ); - })} - -
NameUsername -
{name}{email} - {userId !== id && ( -
- handlePopUpOpen("removeUser", { username, id })} - > - - -
- )} -
- {!isLoading && filterdUsers?.length === 0 && ( - - )} -
-
+ Date: Tue, 9 Jul 2024 00:42:50 +0800 Subject: [PATCH 5/7] misc: renamed mutation function --- frontend/src/hooks/api/admin/index.ts | 2 +- frontend/src/hooks/api/admin/mutation.ts | 2 +- frontend/src/views/admin/DashboardPage/UserPanel.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index 516d98435..fc9fb2c24 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -1,2 +1,2 @@ -export { useCreateAdminUser, useDeleteUser, useUpdateServerConfig } from "./mutation"; +export { useAdminDeleteUser, useCreateAdminUser, useUpdateServerConfig } from "./mutation"; export { useAdminGetUsers, useGetServerConfig } from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index 4f5a6f81e..b927a92b9 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -44,7 +44,7 @@ export const useUpdateServerConfig = () => { }); }; -export const useDeleteUser = () => { +export const useAdminDeleteUser = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (userId: string) => { diff --git a/frontend/src/views/admin/DashboardPage/UserPanel.tsx b/frontend/src/views/admin/DashboardPage/UserPanel.tsx index 16ebceec1..85561f07a 100644 --- a/frontend/src/views/admin/DashboardPage/UserPanel.tsx +++ b/frontend/src/views/admin/DashboardPage/UserPanel.tsx @@ -20,7 +20,7 @@ import { } from "@app/components/v2"; import { useUser } from "@app/context"; import { useDebounce, usePopUp } from "@app/hooks"; -import { useAdminGetUsers, useDeleteUser } from "@app/hooks/api"; +import { useAdminDeleteUser, useAdminGetUsers } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const UserPanelTable = ({ @@ -115,7 +115,7 @@ export const UserPanel = () => { const [searchUserFilter, setSearchUserFilter] = useState(""); - const { mutateAsync: deleteUser } = useDeleteUser(); + const { mutateAsync: deleteUser } = useAdminDeleteUser(); const handleRemoveUser = async () => { const { id } = popUp?.removeUser?.data as { id: string; username: string }; From d0f0dca3a391d77b09c6bd8bd0c993ec8e5e11bb Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Jul 2024 00:57:23 +0800 Subject: [PATCH 6/7] misc: added sort by --- .../src/services/super-admin/super-admin-service.ts | 3 ++- backend/src/services/user/user-dal.ts | 12 ++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index f5987cb15..650910681 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -213,7 +213,8 @@ export const superAdminServiceFactory = ({ return userDAL.getUsersByFilter({ limit, offset, - searchTerm + searchTerm, + sortBy: "username" }); }; diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 1bca2cd43..9ec495ca3 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -7,7 +7,8 @@ import { TUserActionsUpdate, TUserEncryptionKeys, TUserEncryptionKeysInsert, - TUserEncryptionKeysUpdate + TUserEncryptionKeysUpdate, + TUsers } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; @@ -21,11 +22,13 @@ export const userDALFactory = (db: TDbClient) => { const getUsersByFilter = async ({ limit, offset, - searchTerm + searchTerm, + sortBy }: { limit: number; offset: number; searchTerm: string; + sortBy?: keyof TUsers; }) => { try { let query = db.replicaNode()(TableName.Users).where("isGhost", "=", false); @@ -38,6 +41,11 @@ export const userDALFactory = (db: TDbClient) => { .orWhereLike("username", `%${searchTerm}%`); }); } + + if (sortBy) { + query = query.orderBy(sortBy); + } + return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Users)); } catch (error) { throw new DatabaseError({ error, name: "Get users by filter" }); From 2c6decaf6ef2275af685d0f3d2673e8e3cd867c5 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Jul 2024 01:11:24 +0800 Subject: [PATCH 7/7] misc: addressed comments --- backend/src/server/routes/v1/admin-router.ts | 2 +- .../views/admin/DashboardPage/UserPanel.tsx | 135 +++++++++--------- 2 files changed, 68 insertions(+), 69 deletions(-) diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 0169b15c0..6e41df946 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -93,7 +93,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { querystring: z.object({ searchTerm: z.string().default(""), offset: z.coerce.number().default(0), - limit: z.coerce.number().default(20) + limit: z.coerce.number().max(100).default(20) }), response: { 200: z.object({ diff --git a/frontend/src/views/admin/DashboardPage/UserPanel.tsx b/frontend/src/views/admin/DashboardPage/UserPanel.tsx index 85561f07a..733d52624 100644 --- a/frontend/src/views/admin/DashboardPage/UserPanel.tsx +++ b/frontend/src/views/admin/DashboardPage/UserPanel.tsx @@ -24,10 +24,8 @@ import { useAdminDeleteUser, useAdminGetUsers } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const UserPanelTable = ({ - searchUserFilter, handlePopUpOpen }: { - searchUserFilter: string; handlePopUpOpen: ( popUpName: keyof UsePopUpState<["removeUser"]>, data: { @@ -36,6 +34,7 @@ const UserPanelTable = ({ } ) => void; }) => { + const [searchUserFilter, setSearchUserFilter] = useState(""); const { user } = useUser(); const userId = user?.id || ""; const debounedSearchTerm = useDebounce(searchUserFilter, 500); @@ -47,64 +46,72 @@ const UserPanelTable = ({ const isEmpty = !isLoading && !data?.pages?.[0].length; return ( -
- - - - - - - - - - {isLoading && } - {!isLoading && - data?.pages?.map((users) => - users.map(({ username, email, firstName, lastName, id }) => { - const name = firstName || lastName ? `${firstName} ${lastName}` : "-"; + <> + setSearchUserFilter(e.target.value)} + leftIcon={} + placeholder="Search users..." + /> +
+ +
NameUsername -
+ + + + + + + + {isLoading && } + {!isLoading && + data?.pages?.map((users) => + users.map(({ username, email, firstName, lastName, id }) => { + const name = firstName || lastName ? `${firstName} ${lastName}` : "-"; - return ( - - - - - - ); - }) - )} - -
NameUsername +
{name}{email} - {userId !== id && ( -
- handlePopUpOpen("removeUser", { username, id })} - > - - -
- )} -
- {!isLoading && isEmpty && } -
- {!isEmpty && ( - - )} -
+ return ( + + {name} + {email} + + {userId !== id && ( +
+ handlePopUpOpen("removeUser", { username, id })} + > + + +
+ )} + + + ); + }) + )} + + + {!isLoading && isEmpty && } + + {!isEmpty && ( + + )} +
+ ); }; @@ -113,8 +120,6 @@ export const UserPanel = () => { "removeUser" ] as const); - const [searchUserFilter, setSearchUserFilter] = useState(""); - const { mutateAsync: deleteUser } = useAdminDeleteUser(); const handleRemoveUser = async () => { @@ -141,13 +146,7 @@ export const UserPanel = () => {

Users

- setSearchUserFilter(e.target.value)} - leftIcon={} - placeholder="Search users..." - /> - +