import { keepPreviousData } from "@tanstack/react-query"; import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useDebounce } from "@calcom/lib/hooks/useDebounce"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Avatar, Badge, Button, ConfirmationDialogContent, Dialog, DialogClose, DialogContent, DialogFooter, DropdownActions, Icon, showToast, Table, TextField, } from "@calcom/ui"; import { withLicenseRequired } from "../../common/components/LicenseRequired"; const { Cell, ColumnTitle, Header, Row } = Table; const FETCH_LIMIT = 25; function UsersTableBare() { const { t } = useLocale(); const tableContainerRef = useRef(null); const utils = trpc.useUtils(); const [searchTerm, setSearchTerm] = useState(""); const [showImpersonateModal, setShowImpersonateModal] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const debouncedSearchTerm = useDebounce(searchTerm, 500); const router = useRouter(); const mutation = trpc.viewer.users.delete.useMutation({ onSuccess: async () => { showToast("User has been deleted", "success"); // Lets not invalidated the whole cache, just remove the user from the cache. // usefull cause in prod this will be fetching 100k+ users // FIXME: Tested locally and it doesnt't work, need to investigate utils.viewer.admin.listPaginated.setInfiniteData({ limit: FETCH_LIMIT }, (cachedData) => { if (!cachedData) { return { pages: [], pageParams: [], }; } return { ...cachedData, pages: cachedData.pages.map((page) => ({ ...page, rows: page.rows.filter((row) => row.id !== userToDelete), })), }; }); }, onError: (err) => { console.error(err.message); showToast("There has been an error deleting this user.", "error"); }, onSettled: () => { setUserToDelete(null); }, }); const { data, fetchNextPage, isFetching } = trpc.viewer.admin.listPaginated.useInfiniteQuery( { limit: FETCH_LIMIT, searchTerm: debouncedSearchTerm, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, placeholderData: keepPreviousData, refetchOnWindowFocus: false, } ); const sendPasswordResetEmail = trpc.viewer.admin.sendPasswordReset.useMutation({ onSuccess: () => { showToast("Password reset email has been sent", "success"); }, }); const removeTwoFactor = trpc.viewer.admin.removeTwoFactor.useMutation({ onSuccess: () => { showToast("2FA has been removed", "success"); }, }); const lockUserAccount = trpc.viewer.admin.lockUserAccount.useMutation({ onSuccess: ({ userId, locked }) => { showToast(locked ? "User was locked" : "User was unlocked", "success"); utils.viewer.admin.listPaginated.setInfiniteData({ limit: FETCH_LIMIT }, (cachedData) => { if (!cachedData) { return { pages: [], pageParams: [], }; } return { ...cachedData, pages: cachedData.pages.map((page) => ({ ...page, rows: page.rows.map((row) => { const newUser = row; if (row.id === userId) newUser.locked = locked; return newUser; }), })), }; }); }, }); const handleImpersonateUser = async (username: string | null) => { await signIn("impersonation-auth", { redirect: false, username: username }); router.push(`/event-types`); }; //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 ( <> setSearchTerm(e.target.value)} />
fetchMoreOnBottomReached()} style={{ height: "calc(100vh - 30vh)", overflow: "auto", }}>
User Timezone Role Edit
{flatData.map((user) => (
{user.name} /{user.username} {user.locked && ( )}
{user.email}
{user.timeZone} {user.role.toLowerCase()}
sendPasswordResetEmail.mutate({ userId: user.id }), icon: "lock", }, { id: "impersonate-user", label: "Impersonate User", onClick: () => handleImpersonateUser(user?.username), icon: "user", }, { id: "lock-user", label: user.locked ? "Unlock User Account" : "Lock User Account", onClick: () => lockUserAccount.mutate({ userId: user.id, locked: !user.locked }), icon: "lock", }, { id: "impersonation", label: "Impersonate", onClick: () => { setSelectedUser(user.username); setShowImpersonateModal(true); }, icon: "venetian-mask", }, { id: "remove-2fa", label: "Remove 2FA", color: "destructive", onClick: () => removeTwoFactor.mutate({ userId: user.id }), icon: "shield", }, { id: "delete", label: "Delete", color: "destructive", onClick: () => setUserToDelete(user.id), icon: "trash", }, ]} />
))}
setUserToDelete(null)} onConfirm={() => { if (!userToDelete) return; mutation.mutate({ userId: userToDelete }); }} />
{showImpersonateModal && selectedUser && ( setShowImpersonateModal(false)}>
{ e.preventDefault(); await signIn("impersonation-auth", { redirect: false, username: selectedUser }); setShowImpersonateModal(false); router.replace("/settings/my-account/profile"); }}> {t("cancel")}
)} ); } const DeleteUserDialog = ({ user, onConfirm, onClose, }: { user: number | null; onConfirm: () => void; onClose: () => void; }) => { return ( // eslint-disable-next-line @typescript-eslint/no-empty-function -- noop (open ? () => {} : onClose())}>

Are you sure you want to delete this user?

); }; export const UsersTable = withLicenseRequired(UsersTableBare);