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 && ( - - )} -
-
+