feat: Introduce User Report Section on Moderation Console (#30554)

Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com>
pull/31619/head
デワンシュ 2 years ago committed by GitHub
parent da410efa10
commit 2260c04ec6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      .changeset/rare-eels-fetch.md
  2. 142
      apps/meteor/app/api/server/v1/moderation.ts
  3. 33
      apps/meteor/client/views/admin/moderation/MessageContextFooter.tsx
  4. 2
      apps/meteor/client/views/admin/moderation/MessageReportInfo.tsx
  5. 43
      apps/meteor/client/views/admin/moderation/ModConsoleReportDetails.tsx
  6. 55
      apps/meteor/client/views/admin/moderation/ModerationConsolePage.tsx
  7. 35
      apps/meteor/client/views/admin/moderation/ModerationConsoleRoute.tsx
  8. 47
      apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx
  9. 31
      apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx
  10. 45
      apps/meteor/client/views/admin/moderation/UserMessages.tsx
  11. 41
      apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserActions.tsx
  12. 37
      apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserTableRow.tsx
  13. 150
      apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUsersTable.tsx
  14. 30
      apps/meteor/client/views/admin/moderation/UserReports/UserContextFooter.tsx
  15. 116
      apps/meteor/client/views/admin/moderation/UserReports/UserReportInfo.tsx
  16. 133
      apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx
  17. 24
      apps/meteor/client/views/admin/moderation/helpers/ModerationFilter.tsx
  18. 2
      apps/meteor/client/views/admin/moderation/helpers/ReportReason.tsx
  19. 43
      apps/meteor/client/views/admin/moderation/helpers/UserColumn.tsx
  20. 30
      apps/meteor/client/views/admin/moderation/hooks/useDeactivateUserAction.tsx
  21. 2
      apps/meteor/client/views/admin/moderation/hooks/useDeleteMessage.tsx
  22. 9
      apps/meteor/client/views/admin/moderation/hooks/useDeleteMessagesAction.tsx
  23. 2
      apps/meteor/client/views/admin/moderation/hooks/useDismissMessageAction.tsx
  24. 15
      apps/meteor/client/views/admin/moderation/hooks/useDismissUserAction.tsx
  25. 4
      apps/meteor/client/views/admin/moderation/hooks/useResetAvatarAction.tsx
  26. 6
      apps/meteor/client/views/admin/routes.tsx
  27. 5
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
  28. 168
      apps/meteor/server/models/raw/ModerationReports.ts
  29. 17
      apps/meteor/tests/data/moderation.helper.ts
  30. 156
      apps/meteor/tests/end-to-end/api/27-moderation.ts
  31. 20
      packages/model-typings/src/models/IModerationReportsModel.ts
  32. 6
      packages/rest-typings/src/v1/moderation/GetUserReportsParams.ts
  33. 2
      packages/rest-typings/src/v1/moderation/index.ts
  34. 23
      packages/rest-typings/src/v1/moderation/moderation.ts

@ -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.

@ -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<IUser>(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<IUserEmail['address'], IUserEmail>();
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',
{

@ -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 (
<ButtonGroup stretch>
<Button onClick={dismissUserAction.onClick} title={t('Moderation_Dismiss_all_reports')} aria-label={t('Moderation_Dismiss_reports')}>
{t('Moderation_Dismiss_all_reports')}
</Button>
<Button
onClick={deleteMessagesAction.onClick}
title={t('delete-message')}
aria-label={t('Moderation_Delete_all_messages')}
secondary
danger
>
<Button onClick={dismissUserAction.onClick}>{t('Moderation_Dismiss_all_reports')}</Button>
<Button onClick={deleteMessagesAction.onClick} secondary danger>
{t('Moderation_Delete_all_messages')}
</Button>
<GenericMenu
title={t('More')}
items={[
{ ...useDeactivateUserAction(userId), ...(deleted && { disabled: true }) },
{ ...useResetAvatarAction(userId), ...(deleted && { disabled: true }) },
]}
placement='top-end'
/>
<Box flexGrow={0} marginInlineStart={8}>
<GenericMenu
large
title={t('More')}
items={[
{ ...useDeactivateUserAction(userId), ...(deleted && { disabled: true }) },
{ ...useResetAvatarAction(userId), ...(deleted && { disabled: true }) },
]}
placement='top-end'
/>
</Box>
</ButtonGroup>
);
};

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

@ -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<string>(defaultTab);
const moderationRoute = useRouter();
const activeTab = useRouteParameter('tab');
return (
<Contextualbar>
<ContextualbarHeader>
<ContextualbarTitle>{t('Reports')}</ContextualbarTitle>
<ContextualbarClose onClick={() => moderationRoute.navigate(`/admin/moderation/${activeTab}`, { replace: true })} />
</ContextualbarHeader>
<Tabs paddingBlockStart={8}>
<TabsItem selected={tab === 'messages'} onClick={() => setTab('messages')}>
{t('Messages')}
</TabsItem>
<TabsItem selected={tab === 'users'} onClick={() => setTab('users')}>
{t('User')}
</TabsItem>
</Tabs>
{tab === 'messages' && <UserMessages userId={userId} onRedirect={onRedirect} />}
{tab === 'users' && <UserReportInfo userId={userId} />}
</Contextualbar>
);
};
export default ModConsoleReportDetails;

@ -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 (
<Page flexDirection='row'>
<Page>
<PageHeader title={t('Moderation')} />
<Tabs>
<TabsItem selected={tab === 'messages'} onClick={handleTabClick('messages')}>
{t('Reported_Messages')}
</TabsItem>
<TabsItem selected={tab === 'users'} onClick={handleTabClick('users')}>
{t('Reported_Users')}
</TabsItem>
</Tabs>
<PageContent>
<ModerationConsoleTable />
{tab === 'messages' && <ModerationConsoleTable />}
{tab === 'users' && <ModConsoleUsersTable />}
</PageContent>
</Page>
{context && <Contextualbar>{context === 'info' && id && <UserMessages userId={id} onRedirect={handleRedirect} />}</Contextualbar>}
{context === 'info' && id && <ModConsoleReportDetails userId={id} onRedirect={handleRedirect} default={tab} />}
</Page>
);
};

@ -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 <NotAuthorizedPage />;
}
return <ModerationConsolePage />;
const onSelectTab = (tab: (typeof MODERATION_VALID_TABS)[number]) => {
router.navigate(
{
pattern: '/admin/moderation/:tab?/:context?/:id?',
params: { tab },
},
{ replace: true },
);
};
return <ModerationConsolePage tab={tab as (typeof MODERATION_VALID_TABS)[number]} onSelectTab={onSelectTab} />;
};
export default ModerationRoute;

@ -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(
() => [
<GenericTableHeaderCell
@ -81,20 +83,9 @@ const ModerationConsoleTable: FC = () => {
onClick={setSort}
sort='reports.message.u.username'
>
{t('Name')}
{t('User')}
</GenericTableHeaderCell>,
isDesktopOrLarger && (
<GenericTableHeaderCell
w='x140'
key='username'
direction={sortDirection}
active={sortBy === 'reports.message.u.username'}
onClick={setSort}
sort='reports.message.u.username'
>
{t('Username')}
</GenericTableHeaderCell>
),
<GenericTableHeaderCell
key='reportedMessage'
direction={sortDirection}
@ -115,18 +106,12 @@ const ModerationConsoleTable: FC = () => {
</GenericTableHeaderCell>,
<GenericTableHeaderCell key='actions' width='x48' />,
],
[sortDirection, sortBy, setSort, t, isDesktopOrLarger],
[sortDirection, sortBy, setSort, t],
);
return (
<>
<FilterByText autoFocus placeholder={t('Search')} onChange={({ text }): void => setText(text)} />
<Field alignSelf='stretch'>
<FieldLabel>{t('Date')}</FieldLabel>
<FieldRow>
<DateRangePicker display='flex' flexGrow={1} onChange={setDateRange} />
</FieldRow>
</Field>
<ModerationFilter setText={setText} setDateRange={setDateRange} />
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>

@ -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 (
<GenericTableRow key={_id} onClick={(): void => onClick(_id)} tabIndex={0} role='link' action>
<GenericTableCell withTruncatedText>
<Box display='flex' alignItems='center'>
{username && (
<Box>
<UserAvatar size={isDesktopOrLarger ? 'x20' : 'x40'} username={username} />
</Box>
)}
<Box display='flex' mi={8} withTruncatedText>
<Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText>
<Box fontScale='p2m' color='default' withTruncatedText>
{name || username}
</Box>
{!isDesktopOrLarger && name && (
<Box fontScale='p2' color='hint' withTruncatedText>
{' '}
{`@${username}`}{' '}
</Box>
)}
</Box>
</Box>
</Box>
<UserColumn username={username} name={name} fontSize='micro' size={isDesktopOrLarger ? 'x20' : 'x40'} />
</GenericTableCell>
{isDesktopOrLarger && (
<GenericTableCell>
<Box fontScale='p2m' color='hint' withTruncatedText>
{username}
</Box>
</GenericTableCell>
)}
<GenericTableCell withTruncatedText>{message}</GenericTableCell>
<GenericTableCell withTruncatedText>{concatenatedRoomNames}</GenericTableCell>
<GenericTableCell withTruncatedText>{formatDateAndTime(ts)}</GenericTableCell>

@ -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 (
<>
<ContextualbarHeader>
<ContextualbarTitle>{t('Moderation_Message_context_header')}</ContextualbarTitle>
<ContextualbarClose onClick={() => moderationRoute.navigate('/admin/moderation', { replace: true })} />
</ContextualbarHeader>
<Box display='flex' flexDirection='column' width='full' height='full' overflowY='auto' overflowX='hidden'>
{isLoadingUserMessages && <Message>{t('Loading')}</Message>}
{isSuccessUserMessages && (
<Box padding={16}>
{isLoading && <Message>{t('Loading')}</Message>}
{isSuccess && (
<Box padding={24}>
{report.messages.length > 0 && (
<Callout title={t('Moderation_Duplicate_messages')} type='warning' icon='warning'>
{t('Moderation_Duplicate_messages_warning')}
</Callout>
)}
{!report.user && (
<Callout mbs={8} type='warning' icon='warning'>
{t('Moderation_User_deleted_warning')}
@ -63,11 +55,10 @@ const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid
)}
</Box>
)}
{isSuccessUserMessages &&
{isSuccess &&
report.messages.length > 0 &&
report.messages.map((message) => (
<Box key={message._id}>
<Fragment key={message._id}>
<ContextMessage
message={message.message}
room={message.room}
@ -75,9 +66,9 @@ const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid
onChange={handleChange}
deleted={!report.user}
/>
</Box>
</Fragment>
))}
{isSuccessUserMessages && report.messages.length === 0 && <GenericNoResults />}
{isSuccess && report.messages.length === 0 && <GenericNoResults title={t('No_message_reports')} icon='message' />}
{isError && (
<Box display='flex' flexDirection='column' alignItems='center' pb={20} color='default'>
<StatesIcon name='warning' variation='danger' />
@ -88,9 +79,11 @@ const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid
</Box>
)}
</Box>
<ContextualbarFooter display='flex'>
{isSuccessUserMessages && report.messages.length > 0 && <MessageContextFooter userId={userId} deleted={!report.user} />}
</ContextualbarFooter>
{isSuccess && report.messages.length > 0 && (
<ContextualbarFooter>
<MessageContextFooter userId={userId} deleted={!report.user} />
</ContextualbarFooter>
)}
</>
);
};

@ -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<ModConsoleUserRowProps, 'isDesktopOrLarger'>) => {
const t = useTranslation();
const {
reportedUser: { _id: uid },
} = report;
return (
<>
<GenericMenu
title={t('Options')}
sections={[
{
items: [
{
id: 'seeReports',
content: t('Moderation_See_reports'),
icon: 'document-eye',
onClick: () => onClick(uid),
},
],
},
{
items: [useDismissUserAction(uid, true), useDeactivateUserAction(uid, true), useResetAvatarAction(uid)],
},
]}
placement='bottom-end'
/>
</>
);
};
export default ModConsoleUserActions;

@ -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<Pick<UserReport, '_id' | 'reportedUser' | 'ts'> & { 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 (
<GenericTableRow key={_id} onClick={(): void => onClick(_id)} tabIndex={0} role='link' action>
<GenericTableCell withTruncatedText>
<UserColumn name={name} username={username} fontSize='micro' size={isDesktopOrLarger ? 'x20' : 'x40'} />
</GenericTableCell>
<GenericTableCell withTruncatedText>{formatDateAndTime(createdAt)}</GenericTableCell>
<GenericTableCell withTruncatedText>{emails?.[0].address}</GenericTableCell>
<GenericTableCell withTruncatedText>{formatDateAndTime(ts)}</GenericTableCell>
<GenericTableCell withTruncatedText>{count}</GenericTableCell>
<GenericTableCell onClick={(e): void => e.stopPropagation()}>
<ModConsoleUserActions report={report} onClick={onClick} />
</GenericTableCell>
</GenericTableRow>
);
};
export default ModConsoleUserTableRow;

@ -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 = (
<>
<GenericTableHeaderCell
key='name'
direction={sortDirection}
active={sortBy === 'reports.reportedUser.username'}
onClick={setSort}
sort='reports.reportedUser.username'
>
{t('User')}
</GenericTableHeaderCell>
<GenericTableHeaderCell
active={sortBy === 'reports.reportedUser.createdAt'}
onClick={setSort}
sort='reports.reportedUser.createdAt'
key='created'
direction={sortDirection}
>
{t('Created_at')}
</GenericTableHeaderCell>
<GenericTableHeaderCell key='email' direction={sortDirection}>
{t('Email')}
</GenericTableHeaderCell>
<GenericTableHeaderCell key='postdate' direction={sortDirection} active={sortBy === 'reports.ts'} onClick={setSort} sort='reports.ts'>
{t('Moderation_Report_date')}
</GenericTableHeaderCell>
<GenericTableHeaderCell key='reports' direction={sortDirection} active={sortBy === 'count'} onClick={setSort} sort='count'>
{t('Moderation_Report_plural')}
</GenericTableHeaderCell>
<GenericTableHeaderCell key='actions' width='x48' />
</>
);
return (
<>
<ModerationFilter setText={setText} setDateRange={setDateRange} />
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>{isLoading && <GenericTableLoadingTable headerCells={6} />}</GenericTableBody>
</GenericTable>
)}
{isSuccess && data.reports.length > 0 && (
<>
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{data.reports.map((report) => (
<ModConsoleUserTableRow
key={report.reportedUser?._id}
report={report}
onClick={handleClick}
isDesktopOrLarger={isDesktopOrLarger}
/>
))}
</GenericTableBody>
</GenericTable>
<Pagination
current={current}
divider
itemsPerPage={itemsPerPage}
count={data?.total || 0}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
{...paginationProps}
/>
</>
)}
{isSuccess && data.reports.length === 0 && <GenericNoResults />}
{isError && (
<States>
<StatesIcon name='warning' variation='danger' />
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
<StatesActions>
<StatesAction onClick={() => refetch()}>{t('Reload_page')}</StatesAction>
</StatesActions>
</States>
)}
</>
);
};
export default ModConsoleUsersTable;

@ -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 (
<ButtonGroup stretch>
<Button onClick={dismissUserAction.onClick}>{t('Moderation_Dismiss_all_reports')}</Button>
<Button disabled={deleted} onClick={deactivateUserAction.onClick} secondary danger>
{t('Moderation_Deactivate_User')}
</Button>
<Box display='flex' flexGrow={0} marginInlineStart={8}>
<GenericMenu large title={t('More')} items={[{ ...useResetAvatarAction(userId), disabled: deleted }]} placement='top-end' />
</Box>
</ButtonGroup>
);
};
export default UserContextFooter;

@ -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 <UserColumn key={dataUpdatedAt} username={username} name={name} fontSize='p2' size='x48' />;
}, [report?.user, dataUpdatedAt]);
const userEmails = useMemo(() => {
if (!report?.user?.emails) {
return [];
}
return report.user.emails.map((email) => email.address);
}, [report]);
if (isError) {
return (
<Box display='flex' flexDirection='column' alignItems='center' pb={20} color='default'>
<StatesIcon name='warning' variation='danger' />
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
<StatesActions>
<StatesAction onClick={() => reloadUsersReports()}>{t('Reload_page')}</StatesAction>
</StatesActions>
</Box>
);
}
return (
<>
<ContextualbarScrollableContent>
{isLoading && <ContextualbarSkeleton />}
{isSuccess && report.reports.length > 0 && (
<>
{report.user ? (
<FieldGroup>
<Field>{userProfile}</Field>
<Field>
<FieldLabel>{t('Roles')}</FieldLabel>
<FieldRow justifyContent='flex-start' spacing={1}>
{report.user.roles.map((role, index) => (
<UserCard.Role key={index}>{role}</UserCard.Role>
))}
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Email')}</FieldLabel>
<FieldRow>{userEmails.join(', ')}</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Created_at')}</FieldLabel>
<FieldRow>{formatDateAndTime(report.user.createdAt)}</FieldRow>
</Field>
</FieldGroup>
) : (
<Callout mbs={8} type='warning' icon='warning'>
{t('Moderation_User_deleted_warning')}
</Callout>
)}
{report.reports.map((report, ind) => (
<ReportReason key={ind} ind={ind + 1} uinfo={report.reportedBy?.username} msg={report.description} ts={new Date(report.ts)} />
))}
</>
)}
{isSuccess && report.reports.length === 0 && <GenericNoResults title={t('No_user_reports')} icon='user' />}
</ContextualbarScrollableContent>
{isSuccess && report.reports.length > 0 && (
<ContextualbarFooter>
<UserContextFooter userId={userId} deleted={!report.user} />
</ContextualbarFooter>
)}
</>
);
};
export default UserReportInfo;

@ -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<ComponentProps<typeof Box>, '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<SelectOption[]>(() => {
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 (
<Box marginInline={-4} {...props}>
<Margins inline={4}>
<InputBox type='date' value={start} max={todayDate} flexGrow={1} height={20} onChange={handleStart} />
<InputBox type='date' min={start} value={end} max={todayDate} flexGrow={1} height={20} onChange={handleEnd} />
<Menu
options={options}
renderItem={(props: ComponentProps<typeof Option>) => <Option icon='history' {...props} />}
alignSelf='center'
/>
</Margins>
<Box flexGrow={0}>
<Select defaultSelectedKey='alldates' width='100%' options={timeOptions} onChange={handleOptionClick} />
</Box>
);
};

@ -0,0 +1,24 @@
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useCallback } from 'react';
import FilterByText from '../../../../components/FilterByText';
import DateRangePicker from './DateRangePicker';
type ModerationFilterProps = {
setText: (text: string) => void;
setDateRange: (dateRange: { start: string; end: string }) => void;
};
const ModerationFilter = ({ setText, setDateRange }: ModerationFilterProps) => {
const t = useTranslation();
const handleChange = useCallback(({ text }): void => setText(text), [setText]);
return (
<FilterByText autoFocus placeholder={t('Search')} onChange={handleChange}>
<DateRangePicker onChange={setDateRange} />
</FilterByText>
);
};
export default ModerationFilter;

@ -8,7 +8,7 @@ const ReportReason = ({ ind, uinfo, msg, ts }: { ind: number; uinfo: string | un
return (
<Box display='flex' flexDirection='column' alignItems='flex-start' marginBlock={10}>
<Tag variant='danger'>Report #{ind}</Tag>
<Box marginBlock={5} fontSize='p2b'>
<Box wordBreak='break-word' marginBlock={5} fontSize='p2b'>
{msg}
</Box>
<Box>

@ -0,0 +1,43 @@
import { Box } from '@rocket.chat/fuselage';
import React from 'react';
import UserAvatar from '../../../../components/avatar/UserAvatar';
type UserColumnProps = {
name?: string;
username?: string;
isDesktopOrLarger?: boolean;
isProfile?: boolean;
size?: string;
fontSize?: string;
};
const UserColumn = ({ name, username, fontSize, size }: UserColumnProps) => {
return (
<Box display='flex' alignItems='center'>
{username && (
<Box>
<UserAvatar sizes={size} username={username} />
</Box>
)}
<Box display='flex' mi={8} withTruncatedText>
<Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText>
<Box fontScale='p2m' color='default' withTruncatedText>
{name && username ? (
<>
{name}{' '}
<Box display='inline-flex' fontWeight={300} fontSize={fontSize}>
(@{username})
</Box>
</>
) : (
name || username
)}{' '}
</Box>
</Box>
</Box>
</Box>
);
};
export default UserColumn;

@ -1,19 +1,24 @@
import { useEndpoint, useRoute, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import { useEndpoint, useRouteParameter, useRouter, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem';
import GenericModal from '../../../../components/GenericModal';
const useDeactivateUserAction = (userId: string): GenericMenuItemProps => {
const t = useTranslation();
const useDeactivateUserAction = (userId: string, isUserReport?: boolean): GenericMenuItemProps => {
const { t } = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
const deactiveUser = useEndpoint('POST', '/v1/users.setActiveStatus');
const deleteMessages = useEndpoint('POST', '/v1/moderation.user.deleteReportedMessages');
const moderationRoute = useRoute('moderation-console');
const dismissUserReports = useEndpoint('POST', '/v1/moderation.dismissUserReports');
const router = useRouter();
const tab = useRouteParameter('tab');
const handleDeactivateUser = useMutation({
mutationFn: deactiveUser,
@ -35,12 +40,23 @@ const useDeactivateUserAction = (userId: string): GenericMenuItemProps => {
},
});
const handleDismissUserReports = useMutation({
mutationFn: dismissUserReports,
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Moderation_Reports_dismissed_plural') });
},
});
const onDeactivateUser = async () => {
setModal();
await handleDeleteMessages.mutateAsync({ userId });
!isUserReport && (await handleDeleteMessages.mutateAsync({ userId }));
await handleDeactivateUser.mutateAsync({ userId, activeStatus: false, confirmRelinquish: true });
queryClient.invalidateQueries({ queryKey: ['moderation.reports'] });
moderationRoute.push({});
await handleDismissUserReports.mutateAsync({ userId });
queryClient.invalidateQueries({ queryKey: ['moderation'] });
router.navigate(`/admin/moderation/${tab}`);
};
const confirmDeactivateUser = (): void => {

@ -33,7 +33,7 @@ const useDeleteMessage = (mid: string, rid: string, onChange: () => void) => {
},
onSettled: () => {
onChange();
queryClient.invalidateQueries({ queryKey: ['moderation.reports'] });
queryClient.invalidateQueries({ queryKey: ['moderation', 'msgReports'] });
setModal();
},
});

@ -1,4 +1,4 @@
import { useEndpoint, useRoute, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import { useEndpoint, useRouteParameter, useRouter, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React from 'react';
@ -10,7 +10,8 @@ const useDeleteMessagesAction = (userId: string): GenericMenuItemProps => {
const deleteMessages = useEndpoint('POST', '/v1/moderation.user.deleteReportedMessages');
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();
const moderationRoute = useRoute('moderation-console');
const router = useRouter();
const tab = useRouteParameter('tab');
const queryClient = useQueryClient();
const handleDeleteMessages = useMutation({
@ -25,9 +26,9 @@ const useDeleteMessagesAction = (userId: string): GenericMenuItemProps => {
const onDeleteAll = async () => {
await handleDeleteMessages.mutateAsync({ userId });
queryClient.invalidateQueries({ queryKey: ['moderation.reports'] });
queryClient.invalidateQueries({ queryKey: ['moderation', 'msgReports', 'fetchAll'] });
setModal();
moderationRoute.push({});
router.navigate(`/admin/moderation/${tab}`);
};
const confirmDeletMessages = (): void => {

@ -24,7 +24,7 @@ export const useDismissMessageAction = (msgId: string): { action: () => void } =
const onDismissMessage = async () => {
await handleDismissMessage.mutateAsync({ msgId });
queryClient.invalidateQueries({ queryKey: ['moderation.userMessages'] });
queryClient.invalidateQueries({ queryKey: ['moderation', 'msgReports'] });
setModal();
};

@ -1,4 +1,4 @@
import { useEndpoint, useRouter, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useEndpoint, useRouter, useSetModal, useToastMessageDispatch, useRouteParameter } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -6,14 +6,19 @@ import { useTranslation } from 'react-i18next';
import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem';
import GenericModal from '../../../../components/GenericModal';
const useDismissUserAction = (userId: string): GenericMenuItemProps => {
const useDismissUserAction = (userId: string, isUserReport?: boolean): GenericMenuItemProps => {
const { t } = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const moderationRoute = useRouter();
const tab = useRouteParameter('tab');
const queryClient = useQueryClient();
const dismissUser = useEndpoint('POST', '/v1/moderation.dismissReports');
const dismissMsgReports = useEndpoint('POST', '/v1/moderation.dismissReports');
const dismissUserReports = useEndpoint('POST', '/v1/moderation.dismissUserReports');
const dismissUser = isUserReport ? dismissUserReports : dismissMsgReports;
const handleDismissUser = useMutation({
mutationFn: dismissUser,
@ -27,9 +32,9 @@ const useDismissUserAction = (userId: string): GenericMenuItemProps => {
const onDismissUser = async () => {
await handleDismissUser.mutateAsync({ userId });
queryClient.invalidateQueries({ queryKey: ['moderation.reports'] });
queryClient.invalidateQueries({ queryKey: ['moderation', 'userReports'] });
setModal();
moderationRoute.navigate('/admin/moderation', { replace: true });
moderationRoute.navigate(`/admin/moderation/${tab}`, { replace: true });
};
const confirmDismissUser = (): void => {

@ -25,8 +25,8 @@ const useResetAvatarAction = (userId: string): GenericMenuItemProps => {
const onResetAvatar = async () => {
setModal();
handleResetAvatar.mutateAsync({ userId });
queryClient.invalidateQueries({ queryKey: ['moderation.reports'] });
await handleResetAvatar.mutateAsync({ userId });
queryClient.invalidateQueries({ queryKey: ['moderation'] });
};
const confirmResetAvatar = (): void => {

@ -97,8 +97,8 @@ declare module '@rocket.chat/ui-contexts' {
pattern: '/admin/engagement/:tab?';
};
'moderation-console': {
pathname: `/admin/moderation${`/${string}` | ''}${`/${string}` | ''}`;
pattern: '/admin/moderation/:context?/:id?';
pathname: `/admin/moderation${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}`;
pattern: '/admin/moderation/:tab?/:context?/:id?';
};
'subscription': {
pathname: `/admin/subscription`;
@ -218,7 +218,7 @@ registerAdminRoute('/settings/:group?', {
component: lazy(() => import('./settings/SettingsRoute')),
});
registerAdminRoute('/moderation/:context?/:id?', {
registerAdminRoute('/moderation/:tab?/:context?/:id?', {
name: 'moderation-console',
component: lazy(() => import('./moderation/ModerationConsoleRoute')),
});

@ -3584,6 +3584,7 @@
"Mobile_Push_Notifications_Default_Alert": "Push Notifications Default Alert",
"Moderation": "Moderation",
"Moderation_Show_reports": "Show reports",
"Moderation_See_reports": "See reports",
"Moderation_Go_to_message": "Go to message",
"Moderation_Delete_message": "Delete message",
"Moderation_Dismiss_and_delete": "Dismiss and delete",
@ -3761,6 +3762,7 @@
"No_members_found": "No members found",
"No_mentions_found": "No mentions found",
"No_messages_found_to_prune": "No messages found to prune",
"No_message_reports": "No message reports",
"No_messages_yet": "No messages yet",
"No_monitors_yet": "No monitors yet",
"No_monitors_yet_description": "Monitors have partial control of Omnichannel. They can view department analytics and activities of the business units they are assigned.",
@ -3770,6 +3772,7 @@
"No_triggers_yet_description": "Triggers are events that cause the live chat widget to open and send messages automatically.",
"No_units_yet": "No units yet",
"No_units_yet_description": "Use units to group departments and manage them better.",
"No_user_reports": "No user reports",
"No_pages_yet_Try_hitting_Reload_Pages_button": "No pages yet. Try hitting \"Reload Pages\" button.",
"No_pinned_messages": "No pinned messages",
"No_previous_chat_found": "No previous chat found",
@ -4321,6 +4324,8 @@
"Report": "Report",
"Reports": "Reports",
"Report_Abuse": "Report Abuse",
"Reported_Messages": "Reported messages",
"Reported_Users": "Reported users",
"Report_exclamation_mark": "Report!",
"Report_has_been_sent": "Report has been sent",
"Report_Number": "Report Number",

@ -9,6 +9,7 @@ import type {
import type { FindPaginated, IModerationReportsModel, PaginationParams } from '@rocket.chat/model-typings';
import type { AggregationCursor, Collection, Db, Document, FindCursor, FindOptions, IndexDescription, UpdateResult } from 'mongodb';
import { readSecondaryPreferred } from '../../database/readSecondaryPreferred';
import { BaseRaw } from './BaseRaw';
export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements IModerationReportsModel {
@ -18,12 +19,17 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
modelIndexes(): IndexDescription[] | undefined {
return [
{ key: { 'ts': 1, 'reports.ts': 1 } },
{ key: { 'message.u._id': 1, 'ts': 1 } },
{ key: { 'reportedUser._id': 1, 'ts': 1 } },
{ key: { 'message.rid': 1, 'ts': 1 } },
{ key: { userId: 1, ts: 1 } },
{ key: { 'message._id': 1, 'ts': 1 } },
// TODO deprecated. remove within a migration in v7.0
// { key: { 'ts': 1, 'reports.ts': 1 } },
// { key: { 'message.u._id': 1, 'ts': 1 } },
// { key: { 'reportedUser._id': 1, 'ts': 1 } },
// { key: { 'message.rid': 1, 'ts': 1 } },
// { key: { 'message._id': 1, 'ts': 1 } },
// { key: { userId: 1, ts: 1 } },
{ key: { _hidden: 1, ts: 1 } },
{ key: { 'message._id': 1, '_hidden': 1, 'ts': 1 } },
{ key: { 'message.u._id': 1, '_hidden': 1, 'ts': 1 } },
{ key: { 'reportedUser._id': 1, '_hidden': 1, 'ts': 1 } },
];
}
@ -132,6 +138,82 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
return this.col.aggregate(params, { allowDiskUse: true });
}
findUserReports(
latest: Date,
oldest: Date,
selector: string,
pagination: PaginationParams<IModerationReport>,
): AggregationCursor<Pick<UserReport, '_id' | 'reportedUser' | 'ts'> & { count: number }> {
const query = {
_hidden: {
$ne: true,
},
ts: {
$lt: latest,
$gt: oldest,
},
...this.getSearchQueryForSelectorUsers(selector),
};
const { sort, offset, count } = pagination;
const pipeline = [
{ $match: query },
{
$sort: {
ts: -1,
},
},
{
$group: {
_id: '$reportedUser._id',
count: { $sum: 1 },
reports: { $first: '$$ROOT' },
},
},
{
$sort: sort || {
'reports.ts': -1,
},
},
{
$skip: offset,
},
{
$limit: count,
},
{
$project: {
_id: 0,
reportedUser: '$reports.reportedUser',
ts: '$reports.ts',
count: 1,
},
},
];
return this.col.aggregate(pipeline, { allowDiskUse: true, readPreference: readSecondaryPreferred() });
}
async getTotalUniqueReportedUsers(latest: Date, oldest: Date, selector: string, isMessageReports?: boolean): Promise<number> {
const query = {
_hidden: {
$ne: true,
},
ts: {
$lt: latest,
$gt: oldest,
},
...(isMessageReports ? this.getSearchQueryForSelector(selector) : this.getSearchQueryForSelectorUsers(selector)),
};
const field = isMessageReports ? 'message.u._id' : 'reportedUser._id';
const pipeline = [{ $match: query }, { $group: { _id: `$${field}` } }, { $group: { _id: null, count: { $sum: 1 } } }];
const result = await this.col.aggregate(pipeline).toArray();
return result[0]?.count || 0;
}
countMessageReportsInRange(latest: Date, oldest: Date, selector: string): Promise<number> {
return this.col.countDocuments({
_hidden: { $ne: true },
@ -182,6 +264,41 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
return this.findPaginated({ ...query, ...fuzzyQuery }, params);
}
findUserReportsByReportedUserId(
userId: string,
selector: string,
pagination: PaginationParams<IModerationReport>,
options: FindOptions<IModerationReport> = {},
): FindPaginated<FindCursor<Omit<UserReport, 'moderationInfo'>>> {
const query = {
'_hidden': {
$ne: true,
},
'reportedUser._id': userId,
...this.getSearchQueryForSelectorUsers(selector),
};
const { count, offset, sort } = pagination;
const opts = {
sort: sort || {
ts: -1,
},
skip: offset,
limit: count,
projection: {
_id: 1,
description: 1,
ts: 1,
reportedBy: 1,
reportedUser: 1,
},
...options,
};
return this.findPaginated(query, opts);
}
findReportsByMessageId(
messageId: string,
selector: string,
@ -246,6 +363,21 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
return this.updateMany(query, update);
}
async hideUserReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise<UpdateResult | Document> {
const query = {
'reportedUser._id': userId,
};
const update = {
$set: {
_hidden: true,
moderationInfo: { hiddenAt: new Date(), moderatedBy: moderatorId, reason, action },
},
};
return this.updateMany(query, update);
}
private getSearchQueryForSelector(selector?: string): any {
const messageExistsQuery = { message: { $exists: true } };
if (!selector) {
@ -281,4 +413,28 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
],
};
}
private getSearchQueryForSelectorUsers(selector?: string): any {
const messageAbsentQuery = { message: { $exists: false } };
if (!selector) {
return messageAbsentQuery;
}
return {
...messageAbsentQuery,
$or: [
{
'reportedUser.username': {
$regex: selector,
$options: 'i',
},
},
{
'reportedUser.name': {
$regex: selector,
$options: 'i',
},
},
],
};
}
}

@ -0,0 +1,17 @@
import { api, credentials, request } from './api-data';
export const makeModerationApiRequest = async (url: string, method: 'get' | 'post', data?: any) => {
let res: any;
if (method === 'get') {
res = await request.get(api(url)).set(credentials).query(data);
} else if (method === 'post') {
res = await request.post(api(url)).set(credentials).send(data);
}
return res.body;
};
export const reportUser = (userId: string, reason: string) => makeModerationApiRequest('moderation.reportUser', 'post', { userId, reason });
export const getUsersReports = (userId: string) => makeModerationApiRequest('moderation.user.reportsByUserId', 'get', { userId });

@ -4,6 +4,7 @@ import { after, before, describe, it } from 'mocha';
import type { Response } from 'supertest';
import { getCredentials, api, request, credentials } from '../../data/api-data';
import { getUsersReports, reportUser } from '../../data/moderation.helper';
import { createUser, deleteUser } from '../../data/users.helper.js';
// test for the /moderation.reportsByUsers endpoint
@ -73,6 +74,66 @@ describe('[Moderation]', function () {
});
});
describe('[/moderation.userReports]', () => {
it('should return an array of reports', async () => {
await request
.get(api('moderation.userReports'))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('reports').and.to.be.an('array');
});
});
it('should return an array of reports even requested with count and offset params', async () => {
await request
.get(api('moderation.userReports'))
.set(credentials)
.query({
count: 5,
offset: 0,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('reports').and.to.be.an('array');
});
});
it('should return an array of reports even requested with oldest param', async () => {
await request
.get(api('moderation.userReports'))
.set(credentials)
.query({
oldest: new Date(),
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('reports').and.to.be.an('array');
});
});
it('should return an array of reports even requested with latest param', async () => {
await request
.get(api('moderation.userReports'))
.set(credentials)
.query({
latest: new Date(),
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('reports').and.to.be.an('array');
});
});
});
// test for testing out the moderation.dismissReports endpoint
describe('[/moderation.dismissReports]', () => {
@ -98,7 +159,6 @@ describe('[Moderation]', function () {
});
});
// create a reported message by sending a request to chat.reportMessage
beforeEach(async () => {
await request
.post(api('chat.reportMessage'))
@ -191,6 +251,100 @@ describe('[Moderation]', function () {
});
});
describe('[/moderation.user.reportsByUserId]', () => {
let reportedUser: IUser;
before(async () => {
reportedUser = await createUser();
await reportUser(reportedUser._id, 'sample report');
});
after(async () => {
await deleteUser(reportedUser);
});
it('should return an array of reports', async () => {
await request
.get(api('moderation.user.reportsByUserId'))
.set(credentials)
.query({
userId: reportedUser._id,
count: 5,
offset: 0,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect(async (res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('reports').and.to.be.an('array').and.to.have.lengthOf(1);
});
});
it('should return an error when the userId is not provided', async () => {
await request
.get(api('moderation.user.reportsByUserId'))
.set(credentials)
.query({
userId: '',
count: 5,
offset: 0,
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect(async (res: Response) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body).to.have.property('errorType', 'invalid-params');
});
});
});
describe('[/moderation.dismissUserReports', () => {
let reportedUser: IUser;
before(async () => {
reportedUser = await createUser();
await reportUser(reportedUser._id, 'sample report');
});
after(async () => {
await deleteUser(reportedUser);
});
it('should hide reports of a user', async () => {
await request
.post(api('moderation.dismissUserReports'))
.set(credentials)
.send({
userId: reportedUser._id,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
});
await getUsersReports(reportedUser._id).then((res) => {
expect(res.reports).to.have.lengthOf(0);
});
});
it('should return an error when the userId is not provided', async () => {
await request
.post(api('moderation.dismissUserReports'))
.set(credentials)
.send({
userId: '',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res: Response) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error').and.to.be.a('string');
});
});
});
// test for testing out the moderation.reports endpoint
describe('[/moderation.reports]', () => {

@ -1,4 +1,4 @@
import type { IModerationReport, IMessage, IModerationAudit, MessageReport } from '@rocket.chat/core-typings';
import type { IModerationReport, IMessage, IModerationAudit, MessageReport, UserReport } from '@rocket.chat/core-typings';
import type { AggregationCursor, Document, FindCursor, FindOptions, UpdateResult } from 'mongodb';
import type { FindPaginated, IBaseModel } from './IBaseModel';
@ -30,6 +30,15 @@ export interface IModerationReportsModel extends IBaseModel<IModerationReport> {
pagination: PaginationParams<IModerationReport>,
): AggregationCursor<IModerationAudit>;
findUserReports(
latest: Date,
oldest: Date,
selector: string,
pagination: PaginationParams<IModerationReport>,
): AggregationCursor<Pick<UserReport, '_id' | 'reportedUser' | 'ts'> & { count: number }>;
getTotalUniqueReportedUsers(latest: Date, oldest: Date, selector: string, isMessageReports?: boolean): Promise<number>;
countMessageReportsInRange(latest: Date, oldest: Date, selector: string): Promise<number>;
findReportsByMessageId(
@ -46,6 +55,13 @@ export interface IModerationReportsModel extends IBaseModel<IModerationReport> {
options?: FindOptions<IModerationReport>,
): FindPaginated<FindCursor<Pick<MessageReport, '_id' | 'message' | 'ts' | 'room'>>>;
findUserReportsByReportedUserId(
userId: string,
selector: string,
pagination: PaginationParams<IModerationReport>,
options?: FindOptions<IModerationReport>,
): FindPaginated<FindCursor<Omit<UserReport, 'moderationInfo'>>>;
hideMessageReportsByMessageId(
messageId: IMessage['_id'],
userId: string,
@ -54,4 +70,6 @@ export interface IModerationReportsModel extends IBaseModel<IModerationReport> {
): Promise<UpdateResult | Document>;
hideMessageReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise<UpdateResult | Document>;
hideUserReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise<UpdateResult | Document>;
}

@ -1,12 +1,12 @@
import type { PaginatedRequest } from '../../helpers/PaginatedRequest';
import { ajv } from '../Ajv';
type ReportMessageHistoryParams = {
type GetUserReportsParams = {
userId: string;
selector?: string;
};
export type ReportMessageHistoryParamsGET = PaginatedRequest<ReportMessageHistoryParams>;
export type GetUserReportsParamsGET = PaginatedRequest<GetUserReportsParams>;
const ajvParams = {
type: 'object',
@ -37,4 +37,4 @@ const ajvParams = {
additionalProperties: false,
};
export const isReportMessageHistoryParams = ajv.compile<ReportMessageHistoryParamsGET>(ajvParams);
export const isGetUserReportsParams = ajv.compile<GetUserReportsParamsGET>(ajvParams);

@ -5,5 +5,5 @@ export * from './ModerationDeleteMsgHistoryParams';
export * from './ReportHistoryProps';
export * from './ReportInfoParams';
export * from './ReportsByMsgIdParams';
export * from './ReportMessageHistoryParams';
export * from './GetUserReportsParams';
export * from './ModerationReportUserPOST';

@ -1,12 +1,12 @@
import type { IModerationAudit, IModerationReport, IUser, MessageReport } from '@rocket.chat/core-typings';
import type { IModerationAudit, IModerationReport, IUser, MessageReport, UserReport } from '@rocket.chat/core-typings';
import type { PaginatedResult } from '../../helpers/PaginatedResult';
import type { ArchiveReportPropsPOST } from './ArchiveReportProps';
import type { GetUserReportsParamsGET } from './GetUserReportsParams';
import type { ModerationDeleteMsgHistoryParamsPOST } from './ModerationDeleteMsgHistoryParams';
import type { ModerationReportUserPOST } from './ModerationReportUserPOST';
import type { ReportHistoryPropsGET } from './ReportHistoryProps';
import type { ReportInfoParams } from './ReportInfoParams';
import type { ReportMessageHistoryParamsGET } from './ReportMessageHistoryParams';
import type { ReportsByMsgIdParamsGET } from './ReportsByMsgIdParams';
export type ModerationEndpoints = {
@ -19,18 +19,35 @@ export type ModerationEndpoints = {
total: number;
}>;
};
'/v1/moderation.userReports': {
GET: (params: ReportHistoryPropsGET) => PaginatedResult<{
reports: (Pick<UserReport, '_id' | 'reportedUser' | 'ts'> & { count: number })[];
count: number;
offset: number;
total: number;
}>;
};
'/v1/moderation.user.reportedMessages': {
GET: (params: ReportMessageHistoryParamsGET) => PaginatedResult<{
GET: (params: GetUserReportsParamsGET) => PaginatedResult<{
user: Pick<IUser, 'username' | 'name' | '_id'> | null;
messages: Pick<MessageReport, 'message' | 'ts' | 'room' | '_id'>[];
}>;
};
'/v1/moderation.user.reportsByUserId': {
GET: (params: GetUserReportsParamsGET) => PaginatedResult<{
user: IUser | null;
reports: Omit<UserReport, 'moderationInfo'>[];
}>;
};
'/v1/moderation.user.deleteReportedMessages': {
POST: (params: ModerationDeleteMsgHistoryParamsPOST) => void;
};
'/v1/moderation.dismissReports': {
POST: (params: ArchiveReportPropsPOST) => void;
};
'/v1/moderation.dismissUserReports': {
POST: (params: ArchiveReportPropsPOST) => void;
};
'/v1/moderation.reports': {
GET: (params: ReportsByMsgIdParamsGET) => PaginatedResult<{
reports: Pick<IModerationReport, '_id' | 'description' | 'reportedBy' | 'ts' | 'room'>[];

Loading…
Cancel
Save