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 @@
-
-
-
+
{{ $t('group.removeMemberButton') }}
-
+
-
-
-
+
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",