diff --git a/backend/src/graphql/groups.js b/backend/src/graphql/groups.js index e388b2cd9..a7cfc3351 100644 --- a/backend/src/graphql/groups.js +++ b/backend/src/graphql/groups.js @@ -150,6 +150,19 @@ export const changeGroupMemberRoleMutation = () => { ` } +export const removeUserFromGroupMutation = () => { + return gql` + mutation ($groupId: ID!, $userId: ID!) { + RemoveUserFromGroup(groupId: $groupId, userId: $userId) { + id + name + slug + myRoleInGroup + } + } + ` +} + // ------ queries export const groupQuery = () => { diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 9aef8646b..00a34f9ab 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -253,6 +253,42 @@ const isMemberOfGroup = rule({ } }) +const canRemoveUserFromGroup = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const { groupId, userId } = args + const currentUserId = user.id + if (currentUserId === userId) return false + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (User {id: $currentUserId})-[currentUserMembership:MEMBER_OF]->(group:Group {id: $groupId}) + OPTIONAL MATCH (group)<-[userMembership:MEMBER_OF]-(user:User { id: $userId }) + RETURN currentUserMembership.role AS currentUserRole, userMembership.role AS userRole + `, + { currentUserId, groupId, userId }, + ) + return { + currentUserRole: transactionResponse.records.map((record) => + record.get('currentUserRole'), + )[0], + userRole: transactionResponse.records.map((record) => record.get('userRole'))[0], + } + }) + try { + const { currentUserRole, userRole } = await readTxPromise + return ( + currentUserRole && ['owner'].includes(currentUserRole) && userRole && userRole !== 'owner' + ) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + const canCommentPost = rule({ cache: 'no_cache', })(async (_parent, args, { user, driver }) => { @@ -382,6 +418,7 @@ export default shield( JoinGroup: isAllowedToJoinGroup, LeaveGroup: isAllowedToLeaveGroup, ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole, + RemoveUserFromGroup: canRemoveUserFromGroup, CreatePost: and(isAuthenticated, isMemberOfGroup), UpdatePost: isAuthor, DeletePost: isAuthor, diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 4ea588d28..4a13dcc88 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -295,25 +295,8 @@ export default { LeaveGroup: async (_parent, params, context, _resolveInfo) => { const { groupId, userId } = params const session = context.driver.session() - const writeTxResultPromise = session.writeTransaction(async (transaction) => { - const leaveGroupCypher = ` - MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) - DELETE membership - WITH member, group - OPTIONAL MATCH (p:Post)-[:IN]->(group) - WHERE NOT group.groupType = 'public' - WITH member, group, collect(p) AS posts - FOREACH (post IN posts | - MERGE (member)-[:CANNOT_SEE]->(post)) - RETURN member {.*, myRoleInGroup: NULL} - ` - - const transactionResponse = await transaction.run(leaveGroupCypher, { groupId, userId }) - const [member] = await transactionResponse.records.map((record) => record.get('member')) - return member - }) try { - return await writeTxResultPromise + return await removeUserFromGroupWriteTxResultPromise(session, groupId, userId) } catch (error) { throw new Error(error) } finally { @@ -368,6 +351,17 @@ export default { session.close() } }, + RemoveUserFromGroup: async (_parent, params, context, _resolveInfo) => { + const { groupId, userId } = params + const session = context.driver.session() + try { + return await removeUserFromGroupWriteTxResultPromise(session, groupId, userId) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, }, Group: { ...Resolver('Group', { @@ -383,3 +377,27 @@ export default { }), }, } + +const removeUserFromGroupWriteTxResultPromise = async (session, groupId, userId) => { + return session.writeTransaction(async (transaction) => { + const removeUserFromGroupCypher = ` + MATCH (user:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) + DELETE membership + WITH user, group + OPTIONAL MATCH (author:User)-[:WROTE]->(p:Post)-[:IN]->(group) + WHERE NOT group.groupType = 'public' + AND NOT author.id = $userId + WITH user, collect(p) AS posts + FOREACH (post IN posts | + MERGE (user)-[:CANNOT_SEE]->(post)) + RETURN user {.*, myRoleInGroup: NULL} + ` + + const transactionResponse = await transaction.run(removeUserFromGroupCypher, { + groupId, + userId, + }) + const [user] = await transactionResponse.records.map((record) => record.get('user')) + return user + }) +} diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 3b84f4b42..13291383d 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -6,6 +6,7 @@ import { joinGroupMutation, leaveGroupMutation, changeGroupMemberRoleMutation, + removeUserFromGroupMutation, groupMembersQuery, groupQuery, } from '../../graphql/groups' @@ -196,7 +197,6 @@ const seedComplexScenarioAndClearAuthentication = async () => { }, }) // hidden-group - authenticatedUser = await adminMemberUser.toJson() await mutate({ mutation: createGroupMutation(), variables: { @@ -214,32 +214,17 @@ const seedComplexScenarioAndClearAuthentication = async () => { mutation: changeGroupMemberRoleMutation(), variables: { groupId: 'hidden-group', - userId: 'admin-member-user', - roleInGroup: 'usual', - }, - }) - await mutate({ - mutation: changeGroupMemberRoleMutation(), - variables: { - groupId: 'hidden-group', - userId: 'second-owner-member-user', + userId: 'usual-member-user', roleInGroup: 'usual', }, }) + await mutate({ mutation: changeGroupMemberRoleMutation(), variables: { groupId: 'hidden-group', userId: 'admin-member-user', - roleInGroup: 'usual', - }, - }) - await mutate({ - mutation: changeGroupMemberRoleMutation(), - variables: { - groupId: 'hidden-group', - userId: 'second-owner-member-user', - roleInGroup: 'usual', + roleInGroup: 'admin', }, }) @@ -2982,4 +2967,192 @@ describe('in mode', () => { }) }) }) + + describe('RemoveUserFromGroup', () => { + beforeAll(async () => { + await seedComplexScenarioAndClearAuthentication() + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: removeUserFromGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'usual-member-user', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([ + expect.objectContaining({ + message: 'Not Authorized!', + }), + ]), + }) + }) + }) + + describe('authenticated', () => { + describe('as usual member', () => { + it('throws an error', async () => { + authenticatedUser = await usualMemberUser.toJson() + await expect( + mutate({ + mutation: removeUserFromGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'admin-member-user', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([ + expect.objectContaining({ + message: 'Not Authorized!', + }), + ]), + }) + }) + }) + + describe('as owner', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + it('removes the user from the group', async () => { + await expect( + mutate({ + mutation: removeUserFromGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'usual-member-user', + }, + }), + ).resolves.toMatchObject({ + data: { + RemoveUserFromGroup: expect.objectContaining({ + id: 'usual-member-user', + myRoleInGroup: null, + }), + }, + errors: undefined, + }) + }) + + it('cannot remove self', async () => { + await expect( + mutate({ + mutation: removeUserFromGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'owner-member-user', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([ + expect.objectContaining({ + message: 'Not Authorized!', + }), + ]), + }) + }) + }) + + describe('as admin', () => { + beforeEach(async () => { + authenticatedUser = await adminMemberUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'usual-member-user', + roleInGroup: 'usual', + }, + }) + }) + + it('throws an error', async () => { + authenticatedUser = await usualMemberUser.toJson() + await expect( + mutate({ + mutation: removeUserFromGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'admin-member-user', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([ + expect.objectContaining({ + message: 'Not Authorized!', + }), + ]), + }) + }) + + /* + it('removes the user from the group', async () => { + await expect( + mutate({ + mutation: removeUserFromGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'usual-member-user', + }, + }), + ).resolves.toMatchObject({ + data: { + RemoveUserFromGroup: expect.objectContaining({ + id: 'usual-member-user', + myRoleInGroup: null, + }), + }, + errors: undefined, + }) + }) + + it('cannot remove self', async () => { + await expect( + mutate({ + mutation: removeUserFromGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'admin-member-user', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([ + expect.objectContaining({ + message: 'Not Authorized!', + }), + ]), + }) + }) + + it('cannot remove owner', async () => { + await expect( + mutate({ + mutation: removeUserFromGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'owner-member-user', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([ + expect.objectContaining({ + message: 'Not Authorized!', + }), + ]), + }) + }) + */ + }) + }) + }) }) diff --git a/backend/src/schema/resolvers/postsInGroups.spec.js b/backend/src/schema/resolvers/postsInGroups.spec.js index 1eaf7a708..86a278207 100644 --- a/backend/src/schema/resolvers/postsInGroups.spec.js +++ b/backend/src/schema/resolvers/postsInGroups.spec.js @@ -1524,9 +1524,9 @@ describe('Posts in Groups', () => { }) }) - it('does not show the posts of the closed group anymore', async () => { + it('stil shows the posts of the closed group', async () => { const result = await query({ query: filterPosts(), variables: {} }) - expect(result.data.Post).toHaveLength(3) + expect(result.data.Post).toHaveLength(4) expect(result).toMatchObject({ data: { Post: expect.arrayContaining([ @@ -1540,6 +1540,11 @@ describe('Posts in Groups', () => { title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, { id: 'post-to-hidden-group', title: 'A post to a hidden group', @@ -1564,9 +1569,9 @@ describe('Posts in Groups', () => { }) }) - it('does only show the public posts', async () => { + it('still shows the post of the hidden group', async () => { const result = await query({ query: filterPosts(), variables: {} }) - expect(result.data.Post).toHaveLength(2) + expect(result.data.Post).toHaveLength(4) expect(result).toMatchObject({ data: { Post: expect.arrayContaining([ @@ -1580,6 +1585,16 @@ describe('Posts in Groups', () => { title: 'A post without a group', content: 'I am a user who does not belong to a group yet.', }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, ]), }, errors: undefined, @@ -1603,9 +1618,9 @@ describe('Posts in Groups', () => { authenticatedUser = await allGroupsUser.toJson() }) - it('does not show the posts of the closed group', async () => { + it('shows the posts of the closed group', async () => { const result = await query({ query: filterPosts(), variables: {} }) - expect(result.data.Post).toHaveLength(3) + expect(result.data.Post).toHaveLength(4) expect(result).toMatchObject({ data: { Post: expect.arrayContaining([ @@ -1624,6 +1639,11 @@ describe('Posts in Groups', () => { title: 'A post to a closed group', content: 'I am posting into a closed group as a member of the group', }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, ]), }, errors: undefined, diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index ce90fad1d..acf585f71 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -132,4 +132,9 @@ type Mutation { userId: ID! roleInGroup: GroupMemberRole! ): User + + RemoveUserFromGroup( + groupId: ID! + userId: ID! + ): User } diff --git a/webapp/components/Group/GroupMember.spec.js b/webapp/components/Group/GroupMember.spec.js index 0c0b46e43..ef8b96568 100644 --- a/webapp/components/Group/GroupMember.spec.js +++ b/webapp/components/Group/GroupMember.spec.js @@ -1,26 +1,65 @@ import { mount } from '@vue/test-utils' import GroupMember from './GroupMember.vue' +import { changeGroupMemberRoleMutation, removeUserFromGroupMutation } from '~/graphql/groups.js' const localVue = global.localVue const propsData = { - groupId: '', - groupMembers: [], + groupId: 'group-id', + groupMembers: [ + { + slug: 'owner', + id: 'owner', + myRoleInGroup: 'owner', + }, + { + slug: 'user', + id: 'user', + myRoleInGroup: 'usual', + }, + ], } +const stubs = { + 'nuxt-link': true, +} + +const apolloMock = jest + .fn() + .mockRejectedValueOnce({ message: 'Oh no!' }) + .mockResolvedValue({ + data: { + ChangeGroupMemberRole: { + slug: 'user', + id: 'user', + myRoleInGroup: 'admin', + }, + }, + }) + +const toastErrorMock = jest.fn() +const toastSuccessMock = jest.fn() + describe('GroupMember', () => { let wrapper let mocks beforeEach(() => { mocks = { - $t: jest.fn(), + $t: jest.fn((t) => t), + $apollo: { + mutate: apolloMock, + }, + $toast: { + error: toastErrorMock, + success: toastSuccessMock, + }, } }) describe('mount', () => { const Wrapper = () => { - return mount(GroupMember, { propsData, mocks, localVue }) + return mount(GroupMember, { propsData, mocks, localVue, stubs }) } beforeEach(() => { @@ -30,5 +69,120 @@ describe('GroupMember', () => { it('renders', () => { expect(wrapper.findAll('.group-member')).toHaveLength(1) }) + + it('has two users in table', () => { + expect(wrapper.find('tbody').findAll('tr')).toHaveLength(2) + }) + + it('has no modal', () => { + expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false) + }) + + describe('change user role', () => { + beforeEach(() => { + jest.clearAllMocks() + wrapper + .find('tbody') + .findAll('tr') + .at(1) + .find('select') + .findAll('option') + .at(2) + .setSelected() + wrapper.find('tbody').findAll('tr').at(1).find('select').trigger('change') + }) + + describe('with server error', () => { + it('toasts an error message', () => { + expect(toastErrorMock).toBeCalledWith('Oh no!') + }) + }) + + describe('with server success', () => { + it('calls the API', () => { + expect(apolloMock).toBeCalledWith({ + mutation: changeGroupMemberRoleMutation(), + variables: { groupId: 'group-id', userId: 'user', roleInGroup: 'admin' }, + }) + }) + + it('toasts a success message', () => { + expect(toastSuccessMock).toBeCalledWith('group.changeMemberRole') + }) + }) + }) + + describe('click remove user', () => { + beforeAll(() => { + apolloMock.mockRejectedValueOnce({ message: 'Oh no!!' }).mockResolvedValue({ + data: { + RemoveUserFromGroup: { + slug: 'user', + id: 'user', + myRoleInGroup: null, + }, + }, + }) + }) + + beforeEach(() => { + wrapper = Wrapper() + wrapper.find('tbody').findAll('tr').at(1).find('button').trigger('click') + }) + + it('opens the modal', () => { + expect(wrapper.find('div.ds-modal-wrapper').isVisible()).toBe(true) + }) + + describe('click on cancel', () => { + beforeEach(() => { + wrapper.find('div.ds-modal-wrapper').find('button.ds-button-ghost').trigger('click') + }) + + it('closes the modal', () => { + expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false) + }) + }) + + describe('click on confirm with server error', () => { + beforeEach(() => { + wrapper.find('div.ds-modal-wrapper').find('button.ds-button-primary').trigger('click') + }) + + it('toasts an error message', () => { + expect(toastErrorMock).toBeCalledWith('Oh no!!') + }) + + it('closes the modal', () => { + expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false) + }) + }) + + describe('click on confirm with success', () => { + beforeEach(() => { + jest.clearAllMocks() + wrapper.find('div.ds-modal-wrapper').find('button.ds-button-primary').trigger('click') + }) + + it('calls the API', () => { + expect(apolloMock).toBeCalledWith({ + mutation: removeUserFromGroupMutation(), + variables: { groupId: 'group-id', userId: 'user' }, + }) + }) + + it('emits load group members', () => { + expect(wrapper.emitted('loadGroupMembers')).toBeTruthy() + }) + + it('toasts a success message', () => { + expect(toastSuccessMock).toBeCalledWith('group.memberRemoved') + }) + + it('closes the modal', () => { + expect(wrapper.find('div.ds-modal-wrapper').exists()).toBe(false) + }) + }) + }) }) }) diff --git a/webapp/components/Group/GroupMember.vue b/webapp/components/Group/GroupMember.vue index bacd0259d..d6c09746e 100644 --- a/webapp/components/Group/GroupMember.vue +++ b/webapp/components/Group/GroupMember.vue @@ -53,30 +53,33 @@ - - - + diff --git a/webapp/graphql/groups.js b/webapp/graphql/groups.js index cf64c21b3..bb5292675 100644 --- a/webapp/graphql/groups.js +++ b/webapp/graphql/groups.js @@ -143,6 +143,19 @@ export const changeGroupMemberRoleMutation = () => { ` } +export const removeUserFromGroupMutation = () => { + return gql` + mutation ($groupId: ID!, $userId: ID!) { + RemoveUserFromGroup(groupId: $groupId, userId: $userId) { + id + name + slug + myRoleInGroup + } + } + ` +} + // ------ queries export const groupQuery = (i18n) => { diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 1db5a3dcd..c0cae5b08 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -455,6 +455,7 @@ "message": "Eine Gruppe zu verlassen ist möglicherweise nicht rückgängig zu machen!
Gruppe „{name}“ verlassen!", "title": "Möchtest du wirklich die Gruppe verlassen?" }, + "memberRemoved": "Nutzer „{name}“ wurde aus der Gruppe entfernt!", "members": "Mitglieder", "membersAdministrationList": { "avatar": "Avatar", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index f7a7a54ad..3a741a02b 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -455,6 +455,7 @@ "message": "Leaving a group may be irreversible!
Leave group “{name}”!", "title": "Do you really want to leave the group?" }, + "memberRemoved": "User “{name}” was removed from group!", "members": "Members", "membersAdministrationList": { "avatar": "Avatar",