misc: added pagination and moved to admin route

This commit is contained in:
Sheen Capadngan
2024-07-09 00:34:07 +08:00
parent f3a8fda254
commit c1b242db67
14 changed files with 276 additions and 177 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
};
};

View File

@@ -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",

View File

@@ -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<typeof userDALFactory>;
@@ -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
};
};

View File

@@ -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
};
};

View File

@@ -1,2 +1,2 @@
export { useCreateAdminUser, useUpdateServerConfig } from "./mutation";
export { useGetServerConfig } from "./queries";
export { useCreateAdminUser, useDeleteUser, useUpdateServerConfig } from "./mutation";
export { useAdminGetUsers, useGetServerConfig } from "./queries";

View File

@@ -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]
});
}
});
};

View File

@@ -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
});
};

View File

@@ -37,3 +37,8 @@ export type TCreateAdminUserDTO = {
verifier: string;
salt: string;
};
export type AdminGetUsersFilters = {
limit: number;
searchTerm: string;
};

View File

@@ -1,7 +1,6 @@
export {
useAddUserToWsE2EE,
useAddUserToWsNonE2EE,
useDeleteUser,
useSendEmailVerificationCode,
useVerifyEmailVerificationCode
} from "./mutation";
@@ -20,7 +19,6 @@ export {
useGetOrgUsers,
useGetUser,
useGetUserAction,
useListUsers,
useLogoutUser,
useRegisterUserAction,
useRevokeMySessions,

View File

@@ -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);
}
});
};

View File

@@ -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();

View File

@@ -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 (
<div className="mt-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-5/12">Name</Th>
<Th className="w-5/12">Username</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="users" />}
{!isLoading &&
data?.pages?.map((users) =>
users.map(({ username, email, firstName, lastName, id }) => {
const name = firstName || lastName ? `${firstName} ${lastName}` : "-";
return (
<Tr key={`user-${id}`} className="w-full">
<Td className="w-5/12">{name}</Td>
<Td className="w-5/12">{email}</Td>
<Td>
{userId !== id && (
<div className="flex justify-end">
<IconButton
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
isDisabled={userId === id}
onClick={() => handlePopUpOpen("removeUser", { username, id })}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
)}
</Td>
</Tr>
);
})
)}
</TBody>
</Table>
{!isLoading && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of list"}
</Button>
)}
</div>
);
};
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={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search users..."
/>
<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>
<UserPanelTable searchUserFilter={searchUserFilter} handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.removeUser.isOpen}
deleteKey="remove"