diff --git a/apps/web/pages/api/trpc/admin/[trpc].ts b/apps/web/pages/api/trpc/admin/[trpc].ts new file mode 100644 index 0000000000..2bcc5a6a52 --- /dev/null +++ b/apps/web/pages/api/trpc/admin/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { adminRouter } from "@calcom/trpc/server/routers/viewer/admin/_router"; + +export default createNextApiHandler(adminRouter); diff --git a/packages/features/ee/users/components/UserForm.tsx b/packages/features/ee/users/components/UserForm.tsx index 87f387cab9..5eb165a4b3 100644 --- a/packages/features/ee/users/components/UserForm.tsx +++ b/packages/features/ee/users/components/UserForm.tsx @@ -103,7 +103,7 @@ export const UserForm = ({ identityProvider: { value: defaultValues?.identityProvider || identityProviderOptions[0].value, label: - identityProviderOptions.find((option) => option.value === defaultValues?.role)?.label || + identityProviderOptions.find((option) => option.value === defaultValues?.identityProvider)?.label || identityProviderOptions[0].label, }, }, diff --git a/packages/features/ee/users/components/UsersTable.tsx b/packages/features/ee/users/components/UsersTable.tsx index 984dcf406c..25afc60a14 100644 --- a/packages/features/ee/users/components/UsersTable.tsx +++ b/packages/features/ee/users/components/UsersTable.tsx @@ -1,20 +1,52 @@ -import { useState } from "react"; +import { useState, useRef, useMemo, useCallback, useEffect } from "react"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useDebounce } from "@calcom/lib/hooks/useDebounce"; import { trpc } from "@calcom/trpc/react"; -import { Badge, ConfirmationDialogContent, Dialog, DropdownActions, showToast, Table } from "@calcom/ui"; -import { Edit, Trash } from "@calcom/ui/components/icon"; +import { + Badge, + ConfirmationDialogContent, + Dialog, + DropdownActions, + showToast, + Table, + TextField, + Avatar, +} from "@calcom/ui"; +import { Edit, Trash, Lock } from "@calcom/ui/components/icon"; import { withLicenseRequired } from "../../common/components/LicenseRequired"; -const { Body, Cell, ColumnTitle, Header, Row } = Table; +const { Cell, ColumnTitle, Header, Row } = Table; + +const FETCH_LIMIT = 25; function UsersTableBare() { + const tableContainerRef = useRef(null); const utils = trpc.useContext(); - const [data] = trpc.viewer.users.list.useSuspenseQuery(); + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + const mutation = trpc.viewer.users.delete.useMutation({ onSuccess: async () => { showToast("User has been deleted", "success"); - await utils.viewer.users.list.invalidate(); + // Lets not invalidated the whole cache, just remove the user from the cache. + // usefull cause in prod this will be fetching 100k+ users + utils.viewer.admin.listPaginated.setInfiniteData({ limit: FETCH_LIMIT }, (data) => { + if (!data) { + return { + pages: [], + pageParams: [], + }; + } + return { + ...data, + pages: data.pages.map((page) => ({ + ...page, + rows: page.rows.filter((row) => row.id !== userToDelete), + })), + }; + }); }, onError: (err) => { console.error(err.message); @@ -24,89 +56,142 @@ function UsersTableBare() { setUserToDelete(null); }, }); + + const { data, fetchNextPage, isFetching } = trpc.viewer.admin.listPaginated.useInfiniteQuery( + { + limit: FETCH_LIMIT, + searchTerm: debouncedSearchTerm, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + keepPreviousData: true, + refetchOnWindowFocus: false, + } + ); + + const sendPasswordResetEmail = trpc.viewer.admin.sendPasswordReset.useMutation({ + onSuccess: () => { + showToast("Password reset email has been sent", "success"); + }, + }); + + //we must flatten the array of arrays from the useInfiniteQuery hook + const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]); + const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; + const totalFetched = flatData.length; + + //called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table + const fetchMoreOnBottomReached = useCallback( + (containerRefElement?: HTMLDivElement | null) => { + if (containerRefElement) { + const { scrollHeight, scrollTop, clientHeight } = containerRefElement; + //once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any + if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) { + fetchNextPage(); + } + } + }, + [fetchNextPage, isFetching, totalFetched, totalDBRowCount] + ); + + useEffect(() => { + fetchMoreOnBottomReached(tableContainerRef.current); + }, [fetchMoreOnBottomReached]); + const [userToDelete, setUserToDelete] = useState(null); return ( -
- -
- User - Timezone - Role - - Edit - -
- - - {data.map((user) => ( - - - {/* - Using flexbox in a table will mess with the overflow, that's why I used position absolute - to position the image. - */} -
- {`Avatar - -
- {user.name} - /{user.username} -
- {user.email} -
-
-
- {user.timeZone} - - - {user.role.toLowerCase()} - - - -
- setUserToDelete(user.id), - icon: Trash, - }, - ]} - /> -
-
-
- ))} - -
- setUserToDelete(null)} - onConfirm={() => { - if (!userToDelete) return; - mutation.mutate({ userId: userToDelete }); - }} + <> + setSearchTerm(e.target.value)} /> -
+
fetchMoreOnBottomReached()} + style={{ + height: "calc(100vh - 30vh)", + overflow: "auto", + }}> + +
+ User + Timezone + Role + + Edit + +
+ + + {flatData.map((user) => ( + + +
+ + +
+ {user.name} + /{user.username} +
+ {user.email} +
+
+
+ {user.timeZone} + + + {user.role.toLowerCase()} + + + +
+ sendPasswordResetEmail.mutate({ userId: user.id }), + icon: Lock, + }, + { + id: "delete", + label: "Delete", + color: "destructive", + onClick: () => setUserToDelete(user.id), + icon: Trash, + }, + ]} + /> +
+
+
+ ))} + +
+ setUserToDelete(null)} + onConfirm={() => { + if (!userToDelete) return; + mutation.mutate({ userId: userToDelete }); + }} + /> +
+ ); } diff --git a/packages/features/ee/users/schemas/userBodySchema.ts b/packages/features/ee/users/schemas/userBodySchema.ts index ad733e9922..21a528f5a2 100644 --- a/packages/features/ee/users/schemas/userBodySchema.ts +++ b/packages/features/ee/users/schemas/userBodySchema.ts @@ -8,5 +8,6 @@ export const userBodySchema = z role: optionToValueSchema(z.enum(["USER", "ADMIN"])), weekStart: optionToValueSchema(z.string()), timeFormat: optionToValueSchema(z.number()), + identityProvider: optionToValueSchema(z.enum(["CAL", "GOOGLE", "SAML"])), }) .passthrough(); diff --git a/packages/features/ee/users/server/trpc-router.ts b/packages/features/ee/users/server/trpc-router.ts index 2273e6cd2c..eee2b45a31 100644 --- a/packages/features/ee/users/server/trpc-router.ts +++ b/packages/features/ee/users/server/trpc-router.ts @@ -27,6 +27,7 @@ const userBodySchema = User.pick({ // brandColor: true, // darkBrandColor: true, allowDynamicBooking: true, + identityProvider: true, // away: true, role: true, // @note: disallowing avatar changes via API for now. We can add it later if needed. User should upload image via UI. diff --git a/packages/lib/hooks/useDebounce.tsx b/packages/lib/hooks/useDebounce.tsx new file mode 100644 index 0000000000..bfd5d21b9b --- /dev/null +++ b/packages/lib/hooks/useDebounce.tsx @@ -0,0 +1,17 @@ +import { useState, useEffect } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timeout); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/packages/trpc/react/trpc.ts b/packages/trpc/react/trpc.ts index 5e107ede9b..044bbb44d8 100644 --- a/packages/trpc/react/trpc.ts +++ b/packages/trpc/react/trpc.ts @@ -17,6 +17,7 @@ import type { AppRouter } from "../server/routers/_app"; * TODO: Make this dynamic based on folders in trpc server? */ const ENDPOINTS = [ + "admin", "apiKeys", "appRoutingForms", "apps", diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index 9b5a96e91c..1152175223 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -6,6 +6,7 @@ import { insightsRouter } from "@calcom/features/insights/server/trpc-router"; import { mergeRouters, router } from "../../trpc"; import { loggedInViewerRouter } from "../loggedInViewer/_router"; import { publicViewerRouter } from "../publicViewer/_router"; +import { adminRouter } from "./admin/_router"; import { apiKeysRouter } from "./apiKeys/_router"; import { appsRouter } from "./apps/_router"; import { authRouter } from "./auth/_router"; @@ -48,5 +49,6 @@ export const viewerRouter = mergeRouters( appsRouter, users: userAdminRouter, googleWorkspace: googleWorkspaceRouter, + admin: adminRouter, }) ); diff --git a/packages/trpc/server/routers/viewer/admin/_router.ts b/packages/trpc/server/routers/viewer/admin/_router.ts new file mode 100644 index 0000000000..764e2dad1f --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/_router.ts @@ -0,0 +1,50 @@ +import { authedAdminProcedure } from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; +import { ZListMembersSchema } from "./listPaginated.schema"; +import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema"; + +type AdminRouterHandlerCache = { + listPaginated?: typeof import("./listPaginated.handler").listPaginatedHandler; + sendPasswordReset?: typeof import("./sendPasswordReset.handler").sendPasswordResetHandler; +}; + +const UNSTABLE_HANDLER_CACHE: AdminRouterHandlerCache = {}; + +export const adminRouter = router({ + listPaginated: authedAdminProcedure.input(ZListMembersSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.listPaginated) { + UNSTABLE_HANDLER_CACHE.listPaginated = await import("./listPaginated.handler").then( + (mod) => mod.listPaginatedHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.listPaginated) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.listPaginated({ + ctx, + input, + }); + }), + sendPasswordReset: authedAdminProcedure + .input(ZAdminPasswordResetSchema) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.sendPasswordReset) { + UNSTABLE_HANDLER_CACHE.sendPasswordReset = await import("./sendPasswordReset.handler").then( + (mod) => mod.sendPasswordResetHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.sendPasswordReset) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.sendPasswordReset({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts new file mode 100644 index 0000000000..d88de5608a --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts @@ -0,0 +1,68 @@ +import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TListMembersSchema } from "./listPaginated.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TListMembersSchema; +}; + +export const listPaginatedHandler = async ({ ctx, input }: GetOptions) => { + const { cursor, limit, searchTerm } = input; + + const getTotalUsers = await prisma.user.count(); + + let searchFilters: Prisma.UserWhereInput = {}; + + if (searchTerm) { + searchFilters = { + OR: [ + { + email: { + contains: searchTerm.toLowerCase(), + }, + }, + { + username: { + contains: searchTerm.toLocaleLowerCase(), + }, + }, + ], + }; + } + + const users = await prisma.user.findMany({ + cursor: cursor ? { id: cursor } : undefined, + take: limit + 1, // We take +1 as itll be used for the next cursor + where: searchFilters, + orderBy: { + id: "asc", + }, + select: { + id: true, + email: true, + username: true, + name: true, + timeZone: true, + role: true, + }, + }); + + let nextCursor: typeof cursor | undefined = undefined; + if (users && users.length > limit) { + const nextItem = users.pop(); + nextCursor = nextItem!.id; + } + + return { + rows: users || [], + nextCursor, + meta: { + totalRowCount: getTotalUsers || 0, + }, + }; +}; diff --git a/packages/trpc/server/routers/viewer/admin/listPaginated.schema.ts b/packages/trpc/server/routers/viewer/admin/listPaginated.schema.ts new file mode 100644 index 0000000000..236dcf731e --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/listPaginated.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZListMembersSchema = z.object({ + limit: z.number().min(1).max(100), + cursor: z.number().nullish(), + searchTerm: z.string().nullish(), +}); + +export type TListMembersSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/admin/sendPasswordReset.handler.ts b/packages/trpc/server/routers/viewer/admin/sendPasswordReset.handler.ts new file mode 100644 index 0000000000..69559249aa --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/sendPasswordReset.handler.ts @@ -0,0 +1,59 @@ +import dayjs from "@calcom/dayjs"; +import { sendPasswordResetEmail } from "@calcom/emails"; +import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TAdminPasswordResetSchema } from "./sendPasswordReset.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TAdminPasswordResetSchema; +}; + +export const sendPasswordResetHandler = async ({ ctx, input }: GetOptions) => { + const { userId } = input; + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + name: true, + email: true, + locale: true, + }, + }); + + if (!user) { + throw new Error("User not found"); + } + + const t = await getTranslation(user.locale ?? "en", "common"); + + const expiry = dayjs().add(PASSWORD_RESET_EXPIRY_HOURS, "hours").toDate(); + + const passwordResetToken = await prisma.resetPasswordRequest.create({ + data: { + email: user.email, + expires: expiry, + }, + }); + + const resetLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/forgot-password/${passwordResetToken.id}`; + await sendPasswordResetEmail({ + language: t, + user: { + name: user.name, + email: user.email, + }, + resetLink, + }); + + return { + success: true, + }; +}; diff --git a/packages/trpc/server/routers/viewer/admin/sendPasswordReset.schema.ts b/packages/trpc/server/routers/viewer/admin/sendPasswordReset.schema.ts new file mode 100644 index 0000000000..eedc96fc7a --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/sendPasswordReset.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZAdminPasswordResetSchema = z.object({ + userId: z.number(), +}); + +export type TAdminPasswordResetSchema = z.infer;