diff --git a/.changeset/rare-eels-fetch.md b/.changeset/rare-eels-fetch.md new file mode 100644 index 0000000000000..295fce9326565 --- /dev/null +++ b/.changeset/rare-eels-fetch.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +**Added ‘Reported Users’ Tab to Moderation Console:** Enhances user monitoring by displaying reported users. diff --git a/apps/meteor/app/api/server/v1/moderation.ts b/apps/meteor/app/api/server/v1/moderation.ts index fe31487bdc474..d04259b123e22 100644 --- a/apps/meteor/app/api/server/v1/moderation.ts +++ b/apps/meteor/app/api/server/v1/moderation.ts @@ -1,10 +1,10 @@ -import type { IModerationReport, IUser } from '@rocket.chat/core-typings'; +import type { IModerationReport, IUser, IUserEmail } from '@rocket.chat/core-typings'; import { ModerationReports, Users } from '@rocket.chat/models'; import { isReportHistoryProps, isArchiveReportProps, isReportInfoParams, - isReportMessageHistoryParams, + isGetUserReportsParams, isModerationReportUserPost, isModerationDeleteMsgHistoryParams, isReportsByMsgIdParams, @@ -51,7 +51,7 @@ API.v1.addRoute( }); } - const total = await ModerationReports.countMessageReportsInRange(latest, oldest, escapedSelector); + const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector, true); return API.v1.success({ reports, @@ -63,11 +63,60 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'moderation.userReports', + { + authRequired: true, + validateParams: isReportHistoryProps, + permissionsRequired: ['view-moderation-console'], + }, + { + async get() { + const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams; + + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); + + const { sort } = await this.parseJsonQuery(); + + const latest = _latest ? new Date(_latest) : new Date(); + + const oldest = _oldest ? new Date(_oldest) : new Date(0); + + const escapedSelector = escapeRegExp(selector); + + const reports = await ModerationReports.findUserReports(latest, oldest, escapedSelector, { + offset, + count, + sort, + }).toArray(); + + if (reports.length === 0) { + return API.v1.success({ + reports, + count: 0, + offset, + total: 0, + }); + } + + const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector); + + const result = { + reports, + count: reports.length, + offset, + total, + }; + return API.v1.success(result); + }, + }, +); + API.v1.addRoute( 'moderation.user.reportedMessages', { authRequired: true, - validateParams: isReportMessageHistoryParams, + validateParams: isGetUserReportsParams, permissionsRequired: ['view-moderation-console'], }, { @@ -113,6 +162,64 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'moderation.user.reportsByUserId', + { + authRequired: true, + validateParams: isGetUserReportsParams, + permissionsRequired: ['view-moderation-console'], + }, + { + async get() { + const { userId, selector = '' } = this.queryParams; + const { sort } = await this.parseJsonQuery(); + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + + const user = await Users.findOneById(userId, { + projection: { + _id: 1, + username: 1, + name: 1, + avatarETag: 1, + active: 1, + roles: 1, + emails: 1, + createdAt: 1, + }, + }); + + const escapedSelector = escapeRegExp(selector); + const { cursor, totalCount } = ModerationReports.findUserReportsByReportedUserId(userId, escapedSelector, { + offset, + count, + sort, + }); + + const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); + + const emailSet = new Map(); + + reports.forEach((report) => { + const email = report.reportedUser?.emails?.[0]; + if (email) { + emailSet.set(email.address, email); + } + }); + if (user) { + user.emails = Array.from(emailSet.values()); + } + + return API.v1.success({ + user, + reports, + count: reports.length, + total, + offset, + }); + }, + }, +); + API.v1.addRoute( 'moderation.user.deleteReportedMessages', { @@ -196,6 +303,33 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'moderation.dismissUserReports', + { + authRequired: true, + validateParams: isArchiveReportProps, + permissionsRequired: ['manage-moderation-actions'], + }, + { + async post() { + const { userId, reason, action: actionParam } = this.bodyParams; + + if (!userId) { + return API.v1.failure('error-user-id-param-not-provided'); + } + + const sanitizedReason: string = reason ?? 'No reason provided'; + const action: string = actionParam ?? 'None'; + + const { userId: moderatorId } = this; + + await ModerationReports.hideUserReportsByUserId(userId, moderatorId, sanitizedReason, action); + + return API.v1.success(); + }, + }, +); + API.v1.addRoute( 'moderation.reports', { diff --git a/apps/meteor/client/views/admin/moderation/MessageContextFooter.tsx b/apps/meteor/client/views/admin/moderation/MessageContextFooter.tsx index 5f5399962c53b..179a02c26da4c 100644 --- a/apps/meteor/client/views/admin/moderation/MessageContextFooter.tsx +++ b/apps/meteor/client/views/admin/moderation/MessageContextFooter.tsx @@ -1,4 +1,4 @@ -import { Button, ButtonGroup } from '@rocket.chat/fuselage'; +import { Button, ButtonGroup, Box } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import type { FC } from 'react'; @@ -17,27 +17,22 @@ const MessageContextFooter: FC<{ userId: string; deleted: boolean }> = ({ userId return ( - - + - + + + ); }; diff --git a/apps/meteor/client/views/admin/moderation/MessageReportInfo.tsx b/apps/meteor/client/views/admin/moderation/MessageReportInfo.tsx index 758b787e0e4ce..899f33b42ba50 100644 --- a/apps/meteor/client/views/admin/moderation/MessageReportInfo.tsx +++ b/apps/meteor/client/views/admin/moderation/MessageReportInfo.tsx @@ -18,7 +18,7 @@ const MessageReportInfo = ({ msgId }: { msgId: string }): JSX.Element => { isSuccess: isSuccessReportsByMessage, isError: isErrorReportsByMessage, } = useQuery( - ['moderation.reports', { msgId }], + ['moderation', 'msgReports', 'fetchReasons', { msgId }], async () => { const reports = await getReportsByMessage({ msgId }); return reports; diff --git a/apps/meteor/client/views/admin/moderation/ModConsoleReportDetails.tsx b/apps/meteor/client/views/admin/moderation/ModConsoleReportDetails.tsx new file mode 100644 index 0000000000000..bd40de76f015f --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/ModConsoleReportDetails.tsx @@ -0,0 +1,43 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Tabs, TabsItem, ContextualbarHeader, ContextualbarTitle } from '@rocket.chat/fuselage'; +import { useTranslation, useRouter, useRouteParameter } from '@rocket.chat/ui-contexts'; +import React, { useState } from 'react'; + +import { Contextualbar, ContextualbarClose } from '../../../components/Contextualbar'; +import UserMessages from './UserMessages'; +import UserReportInfo from './UserReports/UserReportInfo'; + +type ModConsoleReportDetailsProps = { + userId: IUser['_id']; + default: string; + onRedirect: (mid: string) => void; +}; + +const ModConsoleReportDetails = ({ userId, default: defaultTab, onRedirect }: ModConsoleReportDetailsProps) => { + const t = useTranslation(); + const [tab, setTab] = useState(defaultTab); + const moderationRoute = useRouter(); + + const activeTab = useRouteParameter('tab'); + + return ( + + + {t('Reports')} + moderationRoute.navigate(`/admin/moderation/${activeTab}`, { replace: true })} /> + + + setTab('messages')}> + {t('Messages')} + + setTab('users')}> + {t('User')} + + + {tab === 'messages' && } + {tab === 'users' && } + + ); +}; + +export default ModConsoleReportDetails; diff --git a/apps/meteor/client/views/admin/moderation/ModerationConsolePage.tsx b/apps/meteor/client/views/admin/moderation/ModerationConsolePage.tsx index 6a32478df374f..df1c6d25f1abb 100644 --- a/apps/meteor/client/views/admin/moderation/ModerationConsolePage.tsx +++ b/apps/meteor/client/views/admin/moderation/ModerationConsolePage.tsx @@ -1,37 +1,62 @@ +import { Tabs, TabsItem } from '@rocket.chat/fuselage'; import { useTranslation, useRouteParameter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import React, { useCallback } from 'react'; -import { Contextualbar } from '../../../components/Contextualbar'; import { Page, PageHeader, PageContent } from '../../../components/Page'; import { getPermaLink } from '../../../lib/getPermaLink'; +import ModConsoleReportDetails from './ModConsoleReportDetails'; import ModerationConsoleTable from './ModerationConsoleTable'; -import UserMessages from './UserMessages'; +import ModConsoleUsersTable from './UserReports/ModConsoleUsersTable'; -const ModerationConsolePage = () => { +type TabType = 'users' | 'messages'; + +type ModerationConsolePageProps = { + tab: TabType; + onSelectTab?: (tab: TabType) => void; +}; + +const ModerationConsolePage = ({ tab = 'messages', onSelectTab }: ModerationConsolePageProps) => { const t = useTranslation(); const context = useRouteParameter('context'); const id = useRouteParameter('id'); const dispatchToastMessage = useToastMessageDispatch(); - const handleRedirect = async (mid: string) => { - try { - const permalink = await getPermaLink(mid); - // open the permalink in same tab - window.open(permalink, '_self'); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }; + const handleRedirect = useCallback( + async (mid: string) => { + try { + const permalink = await getPermaLink(mid); + window.open(permalink, '_self'); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [dispatchToastMessage], + ); + + const handleTabClick = useCallback( + (tab: TabType): undefined | (() => void) => (onSelectTab ? (): void => onSelectTab(tab) : undefined), + [onSelectTab], + ); return ( + + + + {t('Reported_Messages')} + + + {t('Reported_Users')} + + - + {tab === 'messages' && } + {tab === 'users' && } - {context && {context === 'info' && id && }} + {context === 'info' && id && } ); }; diff --git a/apps/meteor/client/views/admin/moderation/ModerationConsoleRoute.tsx b/apps/meteor/client/views/admin/moderation/ModerationConsoleRoute.tsx index 29fb50c095c5f..d821c4cb90fa1 100644 --- a/apps/meteor/client/views/admin/moderation/ModerationConsoleRoute.tsx +++ b/apps/meteor/client/views/admin/moderation/ModerationConsoleRoute.tsx @@ -1,16 +1,45 @@ -import { usePermission } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import { usePermission, useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; +import React, { useEffect } from 'react'; import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; import ModerationConsolePage from './ModerationConsolePage'; +const MODERATION_VALID_TABS = ['users', 'messages'] as const; + +const isValidTab = (tab: string | undefined): tab is (typeof MODERATION_VALID_TABS)[number] => MODERATION_VALID_TABS.includes(tab as any); + const ModerationRoute = () => { const canViewModerationConsole = usePermission('view-moderation-console'); + const router = useRouter(); + const tab = useRouteParameter('tab'); + + useEffect(() => { + if (!isValidTab(tab)) { + router.navigate( + { + pattern: '/admin/moderation/:tab?/:context?/:id?', + params: { tab: 'messages' }, + }, + { replace: true }, + ); + } + }, [tab, router]); if (!canViewModerationConsole) { return ; } - return ; + const onSelectTab = (tab: (typeof MODERATION_VALID_TABS)[number]) => { + router.navigate( + { + pattern: '/admin/moderation/:tab?/:context?/:id?', + params: { tab }, + }, + { replace: true }, + ); + }; + + return ; }; + export default ModerationRoute; diff --git a/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx b/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx index da7526cc597ba..aa449efb9eb59 100644 --- a/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx +++ b/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx @@ -1,12 +1,11 @@ -import { Pagination, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; +import { Pagination } from '@rocket.chat/fuselage'; import { useDebouncedValue, useMediaQuery, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useToastMessageDispatch, useRoute } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useToastMessageDispatch, useRouter } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { FC } from 'react'; import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import FilterByText from '../../../components/FilterByText'; import GenericNoResults from '../../../components/GenericNoResults'; import { GenericTable, @@ -18,12 +17,12 @@ import { import { usePagination } from '../../../components/GenericTable/hooks/usePagination'; import { useSort } from '../../../components/GenericTable/hooks/useSort'; import ModerationConsoleTableRow from './ModerationConsoleTableRow'; -import DateRangePicker from './helpers/DateRangePicker'; +import ModerationFilter from './helpers/ModerationFilter'; // TODO: Missing error state const ModerationConsoleTable: FC = () => { const [text, setText] = useState(''); - const moderationRoute = useRoute('moderation-console'); + const router = useRouter(); const { t } = useTranslation(); const isDesktopOrLarger = useMediaQuery('(min-width: 1024px)'); @@ -57,7 +56,7 @@ const ModerationConsoleTable: FC = () => { const dispatchToastMessage = useToastMessageDispatch(); - const { data, isLoading, isSuccess } = useQuery(['moderation.reports', query], async () => getReports(query), { + const { data, isLoading, isSuccess } = useQuery(['moderation', 'msgReports', 'fetchAll', query], async () => getReports(query), { onError: (error) => { dispatchToastMessage({ type: 'error', message: error }); }, @@ -65,13 +64,16 @@ const ModerationConsoleTable: FC = () => { }); const handleClick = useMutableCallback((id): void => { - moderationRoute.push({ - context: 'info', - id, + router.navigate({ + pattern: '/admin/moderation/:tab?/:context?/:id?', + params: { + tab: 'messages', + context: 'info', + id, + }, }); }); - // header sequence would be: name, reportedMessage, room, postdate, reports, actions const headers = useMemo( () => [ { onClick={setSort} sort='reports.message.u.username' > - {t('Name')} + {t('User')} , - isDesktopOrLarger && ( - - {t('Username')} - - ), + { , , ], - [sortDirection, sortBy, setSort, t, isDesktopOrLarger], + [sortDirection, sortBy, setSort, t], ); return ( <> - setText(text)} /> - - {t('Date')} - - - - + {isLoading && ( {headers} diff --git a/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx b/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx index 53bb2fa130a29..56419c61223c7 100644 --- a/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx +++ b/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx @@ -1,11 +1,10 @@ import type { IModerationAudit, IUser } from '@rocket.chat/core-typings'; -import { Box } from '@rocket.chat/fuselage'; import React from 'react'; import { GenericTableCell, GenericTableRow } from '../../../components/GenericTable'; -import UserAvatar from '../../../components/avatar/UserAvatar'; import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; import ModerationConsoleActions from './ModerationConsoleActions'; +import UserColumn from './helpers/UserColumn'; export type ModerationConsoleRowProps = { report: IModerationAudit; @@ -30,34 +29,8 @@ const ModerationConsoleTableRow = ({ report, onClick, isDesktopOrLarger }: Moder return ( onClick(_id)} tabIndex={0} role='link' action> - - {username && ( - - - - )} - - - - {name || username} - - {!isDesktopOrLarger && name && ( - - {' '} - {`@${username}`}{' '} - - )} - - - + - {isDesktopOrLarger && ( - - - {username} - - - )} {message} {concatenatedRoomNames} {formatDateAndTime(ts)} diff --git a/apps/meteor/client/views/admin/moderation/UserMessages.tsx b/apps/meteor/client/views/admin/moderation/UserMessages.tsx index d3f08015611bc..3b3efa68ec5e7 100644 --- a/apps/meteor/client/views/admin/moderation/UserMessages.tsx +++ b/apps/meteor/client/views/admin/moderation/UserMessages.tsx @@ -1,29 +1,27 @@ import { Box, Callout, Message, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useRouter, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -import React from 'react'; +import React, { Fragment } from 'react'; -import { ContextualbarHeader, ContextualbarTitle, ContextualbarClose, ContextualbarFooter } from '../../../components/Contextualbar'; +import { ContextualbarFooter } from '../../../components/Contextualbar'; import GenericNoResults from '../../../components/GenericNoResults'; import MessageContextFooter from './MessageContextFooter'; import ContextMessage from './helpers/ContextMessage'; -// TODO: Missing Error State -const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid: string) => void }): JSX.Element => { +const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid: string) => void }) => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); - const moderationRoute = useRouter(); const getUserMessages = useEndpoint('GET', '/v1/moderation.user.reportedMessages'); const { data: report, refetch: reloadUserMessages, - isLoading: isLoadingUserMessages, - isSuccess: isSuccessUserMessages, + isLoading, + isSuccess, isError, } = useQuery( - ['moderation.userMessages', { userId }], + ['moderation', 'msgReports', 'fetchDetails', { userId }], async () => { const messages = await getUserMessages({ userId }); return messages; @@ -41,21 +39,15 @@ const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid return ( <> - - {t('Moderation_Message_context_header')} - moderationRoute.navigate('/admin/moderation', { replace: true })} /> - - {isLoadingUserMessages && {t('Loading')}} - - {isSuccessUserMessages && ( - + {isLoading && {t('Loading')}} + {isSuccess && ( + {report.messages.length > 0 && ( {t('Moderation_Duplicate_messages_warning')} )} - {!report.user && ( {t('Moderation_User_deleted_warning')} @@ -63,11 +55,10 @@ const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid )} )} - - {isSuccessUserMessages && + {isSuccess && report.messages.length > 0 && report.messages.map((message) => ( - + - + ))} - {isSuccessUserMessages && report.messages.length === 0 && } + {isSuccess && report.messages.length === 0 && } {isError && ( @@ -88,9 +79,11 @@ const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid )} - - {isSuccessUserMessages && report.messages.length > 0 && } - + {isSuccess && report.messages.length > 0 && ( + + + + )} ); }; diff --git a/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserActions.tsx b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserActions.tsx new file mode 100644 index 0000000000000..eb1ec183fcfed --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserActions.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import GenericMenu from '../../../../components/GenericMenu/GenericMenu'; +import useDeactivateUserAction from '../hooks/useDeactivateUserAction'; +import useDismissUserAction from '../hooks/useDismissUserAction'; +import useResetAvatarAction from '../hooks/useResetAvatarAction'; +import type { ModConsoleUserRowProps } from './ModConsoleUserTableRow'; + +const ModConsoleUserActions = ({ report, onClick }: Omit) => { + const t = useTranslation(); + const { + reportedUser: { _id: uid }, + } = report; + + return ( + <> + onClick(uid), + }, + ], + }, + { + items: [useDismissUserAction(uid, true), useDeactivateUserAction(uid, true), useResetAvatarAction(uid)], + }, + ]} + placement='bottom-end' + /> + + ); +}; + +export default ModConsoleUserActions; diff --git a/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserTableRow.tsx b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserTableRow.tsx new file mode 100644 index 0000000000000..b37c5330d7e04 --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserTableRow.tsx @@ -0,0 +1,37 @@ +import type { IUser, UserReport, Serialized } from '@rocket.chat/core-typings'; +import React from 'react'; + +import { GenericTableCell, GenericTableRow } from '../../../../components/GenericTable'; +import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime'; +import UserColumn from '../helpers/UserColumn'; +import ModConsoleUserActions from './ModConsoleUserActions'; + +export type ModConsoleUserRowProps = { + report: Serialized & { count: number }>; + onClick: (id: IUser['_id']) => void; + isDesktopOrLarger: boolean; +}; + +const ModConsoleUserTableRow = ({ report, onClick, isDesktopOrLarger }: ModConsoleUserRowProps): JSX.Element => { + const { reportedUser, count, ts } = report; + const { _id, username, name, createdAt, emails } = reportedUser; + + const formatDateAndTime = useFormatDateAndTime(); + + return ( + onClick(_id)} tabIndex={0} role='link' action> + + + + {formatDateAndTime(createdAt)} + {emails?.[0].address} + {formatDateAndTime(ts)} + {count} + e.stopPropagation()}> + + + + ); +}; + +export default ModConsoleUserTableRow; diff --git a/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUsersTable.tsx b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUsersTable.tsx new file mode 100644 index 0000000000000..921cb7166e820 --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUsersTable.tsx @@ -0,0 +1,150 @@ +import { Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage'; +import { useDebouncedValue, useMediaQuery, useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useRouter } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import type { FC } from 'react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import GenericNoResults from '../../../../components/GenericNoResults'; +import { + GenericTable, + GenericTableLoadingTable, + GenericTableHeaderCell, + GenericTableBody, + GenericTableHeader, +} from '../../../../components/GenericTable'; +import { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; +import { useSort } from '../../../../components/GenericTable/hooks/useSort'; +import ModerationFilter from '../helpers/ModerationFilter'; +import ModConsoleUserTableRow from './ModConsoleUserTableRow'; + +const ModConsoleUsersTable: FC = () => { + const [text, setText] = useState(''); + const router = useRouter(); + const { t } = useTranslation(); + const isDesktopOrLarger = useMediaQuery('(min-width: 1024px)'); + + const { sortBy, sortDirection, setSort } = useSort< + 'reports.ts' | 'reports.reportedUser.username' | 'reports.reportedUser.createdAt' | 'count' + >('reports.ts'); + const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination(); + + const [dateRange, setDateRange] = useState<{ start: string | null; end: string | null }>({ + start: '', + end: '', + }); + const { start, end } = dateRange; + + const debouncedText = useDebouncedValue(text, 500); + + const query = { + selector: debouncedText, + sort: JSON.stringify({ [sortBy]: sortDirection === 'asc' ? 1 : -1 }), + count: itemsPerPage, + offset: current, + latest: end ? `${new Date(end).toISOString().slice(0, 10)}T23:59:59.999Z` : undefined, + oldest: start ? `${new Date(start).toISOString().slice(0, 10)}T00:00:00.000Z` : undefined, + }; + + const getReports = useEndpoint('GET', '/v1/moderation.userReports'); + + const { data, isLoading, isSuccess, isError, refetch } = useQuery( + ['moderation', 'userReports', 'fetchAll', query], + () => getReports(query), + { + keepPreviousData: true, + }, + ); + + const handleClick = useMutableCallback((id): void => { + router.navigate({ + pattern: '/admin/moderation/:tab?/:context?/:id?', + params: { tab: 'users', context: 'info', id }, + }); + }); + + const headers = ( + <> + + {t('User')} + + + {t('Created_at')} + + + {t('Email')} + + + {t('Moderation_Report_date')} + + + {t('Moderation_Report_plural')} + + + + ); + + return ( + <> + + + {isLoading && ( + + {headers} + {isLoading && } + + )} + {isSuccess && data.reports.length > 0 && ( + <> + + {headers} + + {data.reports.map((report) => ( + + ))} + + + + + )} + {isSuccess && data.reports.length === 0 && } + {isError && ( + + + {t('Something_went_wrong')} + + refetch()}>{t('Reload_page')} + + + )} + + ); +}; + +export default ModConsoleUsersTable; diff --git a/apps/meteor/client/views/admin/moderation/UserReports/UserContextFooter.tsx b/apps/meteor/client/views/admin/moderation/UserReports/UserContextFooter.tsx new file mode 100644 index 0000000000000..9beb77c1eb13c --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/UserReports/UserContextFooter.tsx @@ -0,0 +1,30 @@ +import { Button, ButtonGroup, Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; +import type { FC } from 'react'; + +import GenericMenu from '../../../../components/GenericMenu/GenericMenu'; +import useDeactivateUserAction from '../hooks/useDeactivateUserAction'; +import useDismissUserAction from '../hooks/useDismissUserAction'; +import useResetAvatarAction from '../hooks/useResetAvatarAction'; + +const UserContextFooter: FC<{ userId: string; deleted: boolean }> = ({ userId, deleted }) => { + const t = useTranslation(); + + const dismissUserAction = useDismissUserAction(userId, true); + const deactivateUserAction = useDeactivateUserAction(userId, true); + + return ( + + + + + + + + ); +}; + +export default UserContextFooter; diff --git a/apps/meteor/client/views/admin/moderation/UserReports/UserReportInfo.tsx b/apps/meteor/client/views/admin/moderation/UserReports/UserReportInfo.tsx new file mode 100644 index 0000000000000..5d1d99ba8f866 --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/UserReports/UserReportInfo.tsx @@ -0,0 +1,116 @@ +import { + Box, + Callout, + StatesAction, + StatesActions, + StatesIcon, + StatesTitle, + ContextualbarFooter, + FieldGroup, + Field, + FieldLabel, + FieldRow, + ContextualbarSkeleton, +} from '@rocket.chat/fuselage'; +import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import React, { useMemo } from 'react'; + +import { ContextualbarScrollableContent } from '../../../../components/Contextualbar'; +import GenericNoResults from '../../../../components/GenericNoResults'; +import UserCard from '../../../../components/UserCard'; +import { useFormatDate } from '../../../../hooks/useFormatDate'; +import ReportReason from '../helpers/ReportReason'; +import UserColumn from '../helpers/UserColumn'; +import UserContextFooter from './UserContextFooter'; + +const UserReportInfo = ({ userId }: { userId: string }) => { + const t = useTranslation(); + const getUserReports = useEndpoint('GET', '/v1/moderation.user.reportsByUserId'); + const formatDateAndTime = useFormatDate(); + + const { + data: report, + refetch: reloadUsersReports, + isLoading, + isSuccess, + isError, + dataUpdatedAt, + } = useQuery(['moderation', 'userReports', 'fetchDetails', userId], async () => getUserReports({ userId })); + + const userProfile = useMemo(() => { + if (!report?.user) { + return null; + } + + const { username, name } = report.user; + return ; + }, [report?.user, dataUpdatedAt]); + + const userEmails = useMemo(() => { + if (!report?.user?.emails) { + return []; + } + return report.user.emails.map((email) => email.address); + }, [report]); + + if (isError) { + return ( + + + {t('Something_went_wrong')} + + reloadUsersReports()}>{t('Reload_page')} + + + ); + } + + return ( + <> + + {isLoading && } + {isSuccess && report.reports.length > 0 && ( + <> + {report.user ? ( + + {userProfile} + + {t('Roles')} + + {report.user.roles.map((role, index) => ( + {role} + ))} + + + + {t('Email')} + {userEmails.join(', ')} + + + {t('Created_at')} + {formatDateAndTime(report.user.createdAt)} + + + ) : ( + + {t('Moderation_User_deleted_warning')} + + )} + {report.reports.map((report, ind) => ( + + ))} + + )} + {isSuccess && report.reports.length === 0 && } + + {isSuccess && report.reports.length > 0 && ( + + + + )} + + ); +}; + +export default UserReportInfo; diff --git a/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx b/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx index 326cc3382b4bc..74d3f206c8caa 100644 --- a/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx +++ b/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx @@ -1,12 +1,10 @@ -import { Box, InputBox, Menu, Margins, Option } from '@rocket.chat/fuselage'; +import { Select, Box, type SelectOption } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { Moment } from 'moment'; -import moment from 'moment'; -import type { ComponentProps } from 'react'; -import React, { useState, useMemo, useEffect } from 'react'; +import moment, { type Moment } from 'moment'; +import React, { useMemo, useEffect } from 'react'; -type DateRangePickerProps = Omit, 'onChange'> & { +type DateRangePickerProps = { onChange(range: { start: string; end: string }): void; }; @@ -24,101 +22,62 @@ const getWeekRange = (daysToSubtractFromStart: number, daysToSubtractFromEnd: nu end: formatToDateInput(moment().subtract(daysToSubtractFromEnd, 'day')), }); -const DateRangePicker = ({ onChange = () => undefined, ...props }: DateRangePickerProps) => { +const DateRangePicker = ({ onChange }: DateRangePickerProps) => { const t = useTranslation(); - const [range, setRange] = useState({ start: '', end: '' }); - - const { start, end } = range; - - const handleStart = useMutableCallback(({ currentTarget }) => { - const rangeObj = { - start: currentTarget.value, - end: range.end, - }; - setRange(rangeObj); - onChange(rangeObj); - }); - - const handleEnd = useMutableCallback(({ currentTarget }) => { - const rangeObj = { - end: currentTarget.value, - start: range.start, - }; - setRange(rangeObj); - onChange(rangeObj); - }); const handleRange = useMutableCallback((range) => { - setRange(range); onChange(range); }); + const timeOptions = useMemo(() => { + return [ + ['today', t('Today')], + ['yesterday', t('Yesterday')], + ['thisWeek', t('This_week')], + ['previousWeek', t('Previous_week')], + ['thisMonth', t('This_month')], + ['alldates', t('All')], + ].map(([value, label]) => [value, label] as SelectOption); + }, [t]); + useEffect(() => { handleRange({ - start: formatToDateInput(moment().subtract(1, 'month')), + start: formatToDateInput(moment(0)), end: todayDate, }); }, [handleRange]); - const options = useMemo( - () => ({ - today: { - icon: 'history', - label: t('Today'), - action: () => { - handleRange(getWeekRange(0, 0)); - }, - }, - yesterday: { - icon: 'history', - label: t('Yesterday'), - action: () => { - handleRange(getWeekRange(1, 1)); - }, - }, - thisWeek: { - icon: 'history', - label: t('This_week'), - action: () => { - handleRange(getWeekRange(7, 0)); - }, - }, - previousWeek: { - icon: 'history', - label: t('Previous_week'), - action: () => { - handleRange(getWeekRange(14, 7)); - }, - }, - thisMonth: { - icon: 'history', - label: t('This_month'), - action: () => { - handleRange(getMonthRange(0)); - }, - }, - lastMonth: { - icon: 'history', - label: t('Previous_month'), - action: () => { - handleRange(getMonthRange(1)); - }, - }, - }), - [handleRange, t], - ); + const handleOptionClick = useMutableCallback((action) => { + switch (action) { + case 'today': + handleRange(getWeekRange(0, 0)); + break; + case 'yesterday': + handleRange(getWeekRange(1, 1)); + break; + case 'thisWeek': + handleRange(getWeekRange(7, 0)); + break; + case 'previousWeek': + handleRange(getWeekRange(14, 7)); + break; + case 'thisMonth': + handleRange(getMonthRange(0)); + break; + case 'alldates': + handleRange({ + start: formatToDateInput(moment(0)), + end: todayDate, + }); + break; + default: + break; + } + }); return ( - - - - - ) =>