diff --git a/.changeset/ninety-hounds-exist.md b/.changeset/ninety-hounds-exist.md new file mode 100644 index 000000000000..99882de12018 --- /dev/null +++ b/.changeset/ninety-hounds-exist.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/rest-typings': patch +'@rocket.chat/meteor': patch +'@rocket.chat/i18n': patch +--- + +Fix: Show correct user info actions for non-members in channels. diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index e3296b98ef17..17ef75b74574 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1,8 +1,14 @@ import { Media } from '@rocket.chat/core-services'; import type { IRoom, IUpload } from '@rocket.chat/core-typings'; -import { Messages, Rooms, Users, Uploads } from '@rocket.chat/models'; +import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models'; import type { Notifications } from '@rocket.chat/rest-typings'; -import { isGETRoomsNameExists, isRoomsImagesProps, isRoomsMuteUnmuteUserProps, isRoomsExportProps } from '@rocket.chat/rest-typings'; +import { + isGETRoomsNameExists, + isRoomsImagesProps, + isRoomsMuteUnmuteUserProps, + isRoomsExportProps, + isRoomsIsMemberProps, +} from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { isTruthy } from '../../../../lib/isTruthy'; @@ -783,6 +789,36 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'rooms.isMember', + { + authRequired: true, + validateParams: isRoomsIsMemberProps, + }, + { + async get() { + const { roomId, userId, username } = this.queryParams; + const [room, user] = await Promise.all([ + findRoomByIdOrName({ + params: { roomId }, + }) as Promise, + Users.findOneByIdOrUsername(userId || username), + ]); + + if (!user?._id) { + return API.v1.failure('error-user-not-found'); + } + + if (await canAccessRoomAsync(room, { _id: this.user._id })) { + return API.v1.success({ + isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0, + }); + } + return API.v1.unauthorized(); + }, + }, +); + API.v1.addRoute( 'rooms.muteUser', { authRequired: true, validateParams: isRoomsMuteUnmuteUserProps }, diff --git a/apps/meteor/client/views/hooks/useMemberExists.ts b/apps/meteor/client/views/hooks/useMemberExists.ts new file mode 100644 index 000000000000..f737ea6c4e94 --- /dev/null +++ b/apps/meteor/client/views/hooks/useMemberExists.ts @@ -0,0 +1,10 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +type UseMemberExistsProps = { roomId: string; username: string }; + +export const useMemberExists = ({ roomId, username }: UseMemberExistsProps) => { + const checkMember = useEndpoint('GET', '/v1/rooms.isMember'); + + return useQuery(['rooms/isMember', roomId, username], () => checkMember({ roomId, username })); +}; diff --git a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx index da9f9bb19035..a12e9a143cc2 100644 --- a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx +++ b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx @@ -10,6 +10,7 @@ import LocalTime from '../../../components/LocalTime'; import { UserCard, UserCardAction, UserCardRole, UserCardSkeleton } from '../../../components/UserCard'; import { ReactiveUserStatus } from '../../../components/UserStatus'; import { useUserInfoQuery } from '../../../hooks/useUserInfoQuery'; +import { useMemberExists } from '../../hooks/useMemberExists'; import { useUserInfoActions } from '../hooks/useUserInfoActions'; type UserCardWithDataProps = { @@ -24,7 +25,16 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi const getRoles = useRolesDescription(); const showRealNames = Boolean(useSetting('UI_Use_Real_Name')); - const { data, isLoading } = useUserInfoQuery({ username }); + const { data, isLoading: isUserInfoLoading } = useUserInfoQuery({ username }); + const { + data: isMemberData, + refetch, + isSuccess: membershipCheckSuccess, + isLoading: isMembershipStatusLoading, + } = useMemberExists({ roomId: rid, username }); + + const isLoading = isUserInfoLoading || isMembershipStatusLoading; + const isMember = membershipCheckSuccess && isMemberData?.isMember; const user = useMemo(() => { const defaultValue = isLoading ? undefined : null; @@ -62,6 +72,9 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi const { actions: actionsDefinition, menuActions: menuOptions } = useUserInfoActions( { _id: user._id ?? '', username: user.username, name: user.name }, rid, + refetch, + undefined, + isMember, ); const menu = useMemo(() => { diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx index 78d61d9e9f6e..7df7c468bb01 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx @@ -16,7 +16,7 @@ type RoomMembersActionsProps = { const RoomMembersActions = ({ username, _id, name, rid, reload }: RoomMembersActionsProps): ReactElement | null => { const t = useTranslation(); - const { menuActions: menuOptions } = useUserInfoActions({ _id, username, name }, rid, reload, 0); + const { menuActions: menuOptions } = useUserInfoActions({ _id, username, name }, rid, reload, 0, true); if (!menuOptions) { return null; } diff --git a/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx b/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx index 25761539f532..0b4f30fb1f29 100644 --- a/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx +++ b/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx @@ -1,12 +1,13 @@ /* eslint-disable react/display-name, react/no-multi-comp */ import type { IRoom, IUser } from '@rocket.chat/core-typings'; -import { ButtonGroup, IconButton } from '@rocket.chat/fuselage'; +import { ButtonGroup, IconButton, Skeleton } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; import GenericMenu from '../../../../components/GenericMenu/GenericMenu'; import { UserInfoAction } from '../../../../components/UserInfo'; +import { useMemberExists } from '../../../hooks/useMemberExists'; import { useUserInfoActions } from '../../hooks/useUserInfoActions'; type UserInfoActionsProps = { @@ -17,10 +18,24 @@ type UserInfoActionsProps = { const UserInfoActions = ({ user, rid, backToList }: UserInfoActionsProps): ReactElement => { const t = useTranslation(); + const { + data: isMemberData, + refetch, + isSuccess: membershipCheckSuccess, + isLoading, + } = useMemberExists({ roomId: rid, username: user.username as string }); + + const isMember = membershipCheckSuccess && isMemberData?.isMember; + const { actions: actionsDefinition, menuActions: menuOptions } = useUserInfoActions( { _id: user._id, username: user.username, name: user.name }, rid, - backToList, + () => { + backToList?.(); + refetch(); + }, + undefined, + isMember, ); const menu = useMemo(() => { @@ -51,6 +66,9 @@ const UserInfoActions = ({ user, rid, backToList }: UserInfoActionsProps): React return [...actionsDefinition.map(mapAction), menu].filter(Boolean); }, [actionsDefinition, menu]); + if (isLoading) { + return ; + } return {actions}; }; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAddUserAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAddUserAction.tsx new file mode 100644 index 000000000000..42388b100d41 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAddUserAction.tsx @@ -0,0 +1,95 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { isRoomFederated } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { + useTranslation, + useUser, + useUserRoom, + useUserSubscription, + useToastMessageDispatch, + useAtLeastOnePermission, + useEndpoint, +} from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import * as Federation from '../../../../../lib/federation/Federation'; +import { useAddMatrixUsers } from '../../../contextualBar/RoomMembers/AddUsers/AddMatrixUsers/useAddMatrixUsers'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import type { UserInfoAction } from '../useUserInfoActions'; + +const inviteUserEndpoints = { + c: '/v1/channels.invite', + p: '/v1/groups.invite', +} as const; + +export const useAddUserAction = ( + user: Pick, + rid: IRoom['_id'], + reload?: () => void, +): UserInfoAction | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const currentUser = useUser(); + const subscription = useUserSubscription(rid); + const dispatchToastMessage = useToastMessageDispatch(); + + const { username, _id: uid } = user; + + if (!room) { + throw Error('Room not provided'); + } + + const hasPermissionToAddUsers = useAtLeastOnePermission( + useMemo(() => [room?.t === 'p' ? 'add-user-to-any-p-room' : 'add-user-to-any-c-room', 'add-user-to-joined-room'], [room?.t]), + rid, + ); + + const userCanAdd = + room && user && isRoomFederated(room) + ? Federation.isEditableByTheUser(currentUser || undefined, room, subscription) + : hasPermissionToAddUsers; + + const { roomCanInvite } = getRoomDirectives({ room, showingUserId: uid, userSubscription: subscription }); + + const inviteUser = useEndpoint('POST', inviteUserEndpoints[room.t === 'p' ? 'p' : 'c']); + + const handleAddUser = useEffectEvent(async ({ users }) => { + const [username] = users; + await inviteUser({ roomId: rid, username }); + reload?.(); + }); + + const addClickHandler = useAddMatrixUsers(); + + const addUserOptionAction = useEffectEvent(async () => { + try { + const users = [username as string]; + if (isRoomFederated(room)) { + addClickHandler.mutate({ + users, + handleSave: handleAddUser, + }); + } else { + await handleAddUser({ users }); + } + dispatchToastMessage({ type: 'success', message: t('User_added') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error as Error }); + } + }); + + const addUserOption = useMemo( + () => + roomCanInvite && userCanAdd && room.archived !== true + ? { + content: t('add-to-room'), + icon: 'user-plus' as const, + onClick: addUserOptionAction, + type: 'management' as const, + } + : undefined, + [roomCanInvite, userCanAdd, room.archived, t, addUserOptionAction], + ); + + return addUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts index a058fb862ad5..2a70ff55ee34 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts @@ -6,6 +6,7 @@ import { useMemo } from 'react'; import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import { useEmbeddedLayout } from '../../../../hooks/useEmbeddedLayout'; +import { useAddUserAction } from './actions/useAddUserAction'; import { useBlockUserAction } from './actions/useBlockUserAction'; import { useCallAction } from './actions/useCallAction'; import { useChangeLeaderAction } from './actions/useChangeLeaderAction'; @@ -39,7 +40,9 @@ export const useUserInfoActions = ( rid: IRoom['_id'], reload?: () => void, size = 2, + isMember?: boolean, ): { actions: [string, UserInfoAction][]; menuActions: any | undefined } => { + const addUser = useAddUserAction(user, rid, reload); const blockUser = useBlockUserAction(user, rid); const changeLeader = useChangeLeaderAction(user, rid); const changeModerator = useChangeModeratorAction(user, rid); @@ -58,15 +61,16 @@ export const useUserInfoActions = ( () => ({ ...(openDirectMessage && !isLayoutEmbedded && { openDirectMessage }), ...(call && { call }), - ...(changeOwner && { changeOwner }), - ...(changeLeader && { changeLeader }), - ...(changeModerator && { changeModerator }), - ...(openModerationConsole && { openModerationConsole }), - ...(ignoreUser && { ignoreUser }), - ...(muteUser && { muteUser }), + ...(!isMember && addUser && { addUser }), + ...(isMember && changeOwner && { changeOwner }), + ...(isMember && changeLeader && { changeLeader }), + ...(isMember && changeModerator && { changeModerator }), + ...(isMember && openModerationConsole && { openModerationConsole }), + ...(isMember && ignoreUser && { ignoreUser }), + ...(isMember && muteUser && { muteUser }), ...(blockUser && { toggleBlock: blockUser }), ...(reportUserOption && { reportUser: reportUserOption }), - ...(removeUser && { removeUser }), + ...(isMember && removeUser && { removeUser }), }), [ openDirectMessage, @@ -81,6 +85,8 @@ export const useUserInfoActions = ( removeUser, reportUserOption, openModerationConsole, + addUser, + isMember, ], ); diff --git a/apps/meteor/client/views/room/lib/getRoomDirectives.ts b/apps/meteor/client/views/room/lib/getRoomDirectives.ts index f03d41622606..f697fc7b51a6 100644 --- a/apps/meteor/client/views/room/lib/getRoomDirectives.ts +++ b/apps/meteor/client/views/room/lib/getRoomDirectives.ts @@ -11,6 +11,7 @@ type getRoomDirectiesType = { roomCanBlock: boolean; roomCanMute: boolean; roomCanRemove: boolean; + roomCanInvite: boolean; }; export const getRoomDirectives = ({ @@ -24,7 +25,7 @@ export const getRoomDirectives = ({ }): getRoomDirectiesType => { const roomDirectives = room?.t && roomCoordinator.getRoomDirectives(room.t); - const [roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove] = [ + const [roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove, roomCanInvite] = [ ...((roomDirectives && [ roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_OWNER, showingUserId, userSubscription), roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_LEADER, showingUserId, userSubscription), @@ -33,9 +34,10 @@ export const getRoomDirectives = ({ roomDirectives.allowMemberAction(room, RoomMemberActions.BLOCK, showingUserId, userSubscription), roomDirectives.allowMemberAction(room, RoomMemberActions.MUTE, showingUserId, userSubscription), roomDirectives.allowMemberAction(room, RoomMemberActions.REMOVE_USER, showingUserId, userSubscription), + roomDirectives.allowMemberAction(room, RoomMemberActions.INVITE, showingUserId, userSubscription), ]) ?? []), ]; - return { roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove }; + return { roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove, roomCanInvite }; }; diff --git a/apps/meteor/tests/e2e/user-card-info-actions-by-member.spec.ts b/apps/meteor/tests/e2e/user-card-info-actions-by-member.spec.ts new file mode 100644 index 000000000000..b664f2c9ee69 --- /dev/null +++ b/apps/meteor/tests/e2e/user-card-info-actions-by-member.spec.ts @@ -0,0 +1,90 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.user3.state }); + +test.describe.parallel('Mention User Card [To Member]', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: [Users.user1.data.username, Users.user3.data.username] }); + + await api.post(`/chat.postMessage`, { + text: `Hello @${Users.user1.data.username} @${Users.user2.data.username}`, + channel: targetChannel, + }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await page.goto('/home'); + }); + + test.afterAll(({ api }) => deleteChannel(api, targetChannel)); + + test('should show correct userinfo actions for a member of the room to a non-privileged member', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user1.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeFalsy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeFalsy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeFalsy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeFalsy(); + }); + + test('should show correct userinfo actions for a non-member of the room to a non-privileged member', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user2.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeFalsy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeFalsy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeFalsy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeFalsy(); + }); +}); diff --git a/apps/meteor/tests/e2e/user-card-info-actions-by-room-owner.spec.ts b/apps/meteor/tests/e2e/user-card-info-actions-by-room-owner.spec.ts new file mode 100644 index 000000000000..808acbb79bbd --- /dev/null +++ b/apps/meteor/tests/e2e/user-card-info-actions-by-room-owner.spec.ts @@ -0,0 +1,90 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); +test.describe.parallel('Mention User Card [To Room Owner]', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: [Users.user1.data.username] }); + + await api.post(`/chat.postMessage`, { + text: `Hello @${Users.user1.data.username} @${Users.user2.data.username}`, + channel: targetChannel, + }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await page.goto('/home'); + }); + + test.afterAll(({ api }) => deleteChannel(api, targetChannel)); + + test('should show correct userinfo actions for a member of the room to the room owner', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user1.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeFalsy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeTruthy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeTruthy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeTruthy(); + }); + + test('should show correct userinfo actions for a non-member of the room to the room owner', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user2.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeTruthy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeFalsy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeFalsy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeFalsy(); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index fc0a87a8e260..981388c416a9 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -3032,4 +3032,286 @@ describe('[Rooms]', () => { }); }); }); + describe('/rooms.isMember', () => { + let testChannel: IRoom; + let testGroup: IRoom; + let testDM: IRoom; + + const fakeRoomId = `room.test.${Date.now()}-${Math.random()}`; + const fakeUserId = `user.test.${Date.now()}-${Math.random()}`; + + const testChannelName = `channel.test.${Date.now()}-${Math.random()}`; + const testGroupName = `group.test.${Date.now()}-${Math.random()}`; + + let testUser1: TestUser; + let testUser2: TestUser; + let testUserNonMember: TestUser; + let testUser1Credentials: Credentials; + let testUserNonMemberCredentials: Credentials; + + before(async () => { + testUser1 = await createUser(); + testUser1Credentials = await login(testUser1.username, password); + }); + + before(async () => { + testUser2 = await createUser(); + }); + + before(async () => { + testUserNonMember = await createUser(); + testUserNonMemberCredentials = await login(testUserNonMember.username, password); + }); + + before(async () => { + const response = await createRoom({ + type: 'c', + name: testChannelName, + members: [testUser1.username, testUser2.username], + }); + testChannel = response.body.channel; + }); + + before(async () => { + const response = await createRoom({ + type: 'p', + name: testGroupName, + members: [testUser1.username, testUser2.username], + }); + testGroup = response.body.group; + }); + + before(async () => { + const response = await createRoom({ + type: 'd', + username: testUser2.username, + credentials: testUser1Credentials, + }); + testDM = response.body.room; + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'c', roomId: testChannel._id }), + deleteRoom({ type: 'p', roomId: testGroup._id }), + deleteRoom({ type: 'd', roomId: testDM._id }), + deleteUser(testUser1), + deleteUser(testUser2), + deleteUser(testUserNonMember), + ]), + ); + + it('should return error if room not found', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: fakeRoomId, + userId: testUser1._id, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property( + 'error', + 'The required "roomId" or "roomName" param provided does not match any channel [error-room-not-found]', + ); + }); + }); + + it('should return error if user not found with the given userId', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + userId: fakeUserId, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-user-not-found'); + }); + }); + + it('should return error if user not found with the given username', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + username: fakeUserId, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-user-not-found'); + }); + }); + + it('should return success with isMember=true if given userId is a member of the channel', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + userId: testUser2._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=true if given username is a member of the channel', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + username: testUser2.username, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=false if user is not a member of the channel', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + userId: testUserNonMember._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', false); + }); + }); + + it('should return success with isMember=true if given userId is a member of the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testGroup._id, + userId: testUser2._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=true if given username is a member of the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testGroup._id, + username: testUser2.username, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=false if user is not a member of the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testGroup._id, + userId: testUserNonMember._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', false); + }); + }); + + it('should return unauthorized if caller cannot access the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUserNonMemberCredentials) + .query({ + roomId: testGroup._id, + userId: testUser1._id, + }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + + it('should return success with isMember=true if given userId is a member of the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testDM._id, + userId: testUser2._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=true if given username is a member of the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testDM._id, + username: testUser2.username, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=false if user is not a member of the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testDM._id, + userId: testUserNonMember._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', false); + }); + }); + + it('should return unauthorized if caller cannot access the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUserNonMemberCredentials) + .query({ + roomId: testDM._id, + userId: testUser1._id, + }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + }); }); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 5c5010522b27..204cc75b79f5 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -331,6 +331,7 @@ "Add_User": "Add User", "Add_users": "Add users", "Add_members": "Add Members", + "add-to-room": "Add to room", "add-all-to-room": "Add all users to a room", "add-all-to-room_description": "Permission to add all users to a room", "add-livechat-department-agents": "Add Omnichannel Agents to Departments", diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index 1c0b6a360f7b..cefa1321402f 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -447,6 +447,21 @@ const GETRoomsNameExistsSchema = { export const isGETRoomsNameExists = ajv.compile(GETRoomsNameExistsSchema); +type RoomsIsMemberProps = { roomId: string } & ({ username: string } | { userId: string }); + +const RoomsIsMemberPropsSchema = { + type: 'object', + properties: { + roomId: { type: 'string', minLength: 1 }, + userId: { type: 'string', minLength: 1 }, + username: { type: 'string', minLength: 1 }, + }, + oneOf: [{ required: ['roomId', 'userId'] }, { required: ['roomId', 'username'] }], + additionalProperties: false, +}; + +export const isRoomsIsMemberProps = ajv.compile(RoomsIsMemberPropsSchema); + export type Notifications = { disableNotifications: string; muteGroupMentions: string; @@ -685,6 +700,10 @@ export type RoomsEndpoints = { }>; }; + '/v1/rooms.isMember': { + GET: (params: RoomsIsMemberProps) => { isMember: boolean }; + }; + '/v1/rooms.muteUser': { POST: (params: RoomsMuteUnmuteUser) => void; };