diff --git a/backend/src/db/graphql/groups.js b/backend/src/db/graphql/groups.js index c5c6756ed..4350e19c9 100644 --- a/backend/src/db/graphql/groups.js +++ b/backend/src/db/graphql/groups.js @@ -106,6 +106,17 @@ export const joinGroupMutation = gql` } ` +export const leaveGroupMutation = gql` + mutation ($groupId: ID!, $userId: ID!) { + LeaveGroup(groupId: $groupId, userId: $userId) { + id + name + slug + myRoleInGroup + } + } +` + export const changeGroupMemberRoleMutation = gql` mutation ($groupId: ID!, $userId: ID!, $roleInGroup: GroupMemberRole!) { ChangeGroupMemberRole(groupId: $groupId, userId: $userId, roleInGroup: $roleInGroup) { @@ -149,8 +160,8 @@ export const groupQuery = gql` ` export const groupMembersQuery = gql` - query ($id: ID!, $first: Int, $offset: Int, $orderBy: [_UserOrdering], $filter: _UserFilter) { - GroupMembers(id: $id, first: $first, offset: $offset, orderBy: $orderBy, filter: $filter) { + query ($id: ID!) { + GroupMembers(id: $id) { id name slug diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index e588e1b62..8f3d6947a 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -191,6 +191,36 @@ const isAllowedToJoinGroup = rule({ } }) +const isAllowedToLeaveGroup = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const { groupId, userId } = args + if (user.id !== userId) return false + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) + RETURN group {.*}, member {.*, myRoleInGroup: membership.role} + `, + { groupId, userId }, + ) + return { + group: transactionResponse.records.map((record) => record.get('group'))[0], + member: transactionResponse.records.map((record) => record.get('member'))[0], + } + }) + try { + const { group, member } = await readTxPromise + return !!group && !!member && !!member.myRoleInGroup && member.myRoleInGroup !== 'owner' + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + const isAuthor = rule({ cache: 'no_cache', })(async (_parent, args, { user, driver }) => { @@ -284,6 +314,7 @@ export default shield( CreateGroup: isAuthenticated, UpdateGroup: isAllowedToChangeGroupSettings, JoinGroup: isAllowedToJoinGroup, + LeaveGroup: isAllowedToLeaveGroup, ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole, CreatePost: isAuthenticated, UpdatePost: isAuthor, diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 6a5bd7966..babef1d51 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -244,6 +244,27 @@ export default { session.close() } }, + 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 + 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 + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, ChangeGroupMemberRole: async (_parent, params, context, _resolveInfo) => { const { groupId, userId, roleInGroup } = params const session = context.driver.session() diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 03b49e64a..b4653edaf 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -4,6 +4,7 @@ import { createGroupMutation, updateGroupMutation, joinGroupMutation, + leaveGroupMutation, changeGroupMemberRoleMutation, groupMembersQuery, groupQuery, @@ -17,6 +18,12 @@ const neode = getNeode() let authenticatedUser let user +let noMemberUser +let pendingMemberUser +let usualMemberUser +let adminMemberUser +let ownerMemberUser +let secondOwnerMemberUser const categoryIds = ['cat9', 'cat4', 'cat15'] const descriptionAdditional100 = @@ -76,6 +83,169 @@ const seedBasicsAndClearAuthentication = async () => { authenticatedUser = null } +const seedComplexScenarioAndClearAuthentication = async () => { + await seedBasicsAndClearAuthentication() + // create users + noMemberUser = await Factory.build( + 'user', + { + id: 'none-member-user', + name: 'None Member TestUser', + }, + { + email: 'none-member-user@example.org', + password: '1234', + }, + ) + pendingMemberUser = await Factory.build( + 'user', + { + id: 'pending-member-user', + name: 'Pending Member TestUser', + }, + { + email: 'pending-member-user@example.org', + password: '1234', + }, + ) + usualMemberUser = await Factory.build( + 'user', + { + id: 'usual-member-user', + name: 'Usual Member TestUser', + }, + { + email: 'usual-member-user@example.org', + password: '1234', + }, + ) + adminMemberUser = await Factory.build( + 'user', + { + id: 'admin-member-user', + name: 'Admin Member TestUser', + }, + { + email: 'admin-member-user@example.org', + password: '1234', + }, + ) + ownerMemberUser = await Factory.build( + 'user', + { + id: 'owner-member-user', + name: 'Owner Member TestUser', + }, + { + email: 'owner-member-user@example.org', + password: '1234', + }, + ) + secondOwnerMemberUser = await Factory.build( + 'user', + { + id: 'second-owner-member-user', + name: 'Second Owner Member TestUser', + }, + { + email: 'second-owner-member-user@example.org', + password: '1234', + }, + ) + // create groups + // public-group + authenticatedUser = await usualMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'public-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'public-group', + userId: 'owner-of-closed-group', + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'public-group', + userId: 'owner-of-hidden-group', + }, + }) + // closed-group + authenticatedUser = await ownerMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'closed-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + // hidden-group + authenticatedUser = await adminMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'hidden-group', + name: 'Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + // 'JoinGroup' mutation does not work in hidden groups so we join them by 'ChangeGroupMemberRole' through the owner + 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', + }, + }) + 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', + }, + }) + + authenticatedUser = null +} + beforeAll(async () => { await cleanDatabase() }) @@ -1032,8 +1202,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1065,8 +1235,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1098,8 +1268,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1141,8 +1311,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1174,8 +1344,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1239,8 +1409,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1276,8 +1446,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1313,8 +1483,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1371,162 +1541,8 @@ describe('in mode', () => { }) describe('ChangeGroupMemberRole', () => { - let pendingMemberUser - let usualMemberUser - let adminMemberUser - let ownerMemberUser - let secondOwnerMemberUser - beforeAll(async () => { - await seedBasicsAndClearAuthentication() - // create users - pendingMemberUser = await Factory.build( - 'user', - { - id: 'pending-member-user', - name: 'Pending Member TestUser', - }, - { - email: 'pending-member-user@example.org', - password: '1234', - }, - ) - usualMemberUser = await Factory.build( - 'user', - { - id: 'usual-member-user', - name: 'Usual Member TestUser', - }, - { - email: 'usual-member-user@example.org', - password: '1234', - }, - ) - adminMemberUser = await Factory.build( - 'user', - { - id: 'admin-member-user', - name: 'Admin Member TestUser', - }, - { - email: 'admin-member-user@example.org', - password: '1234', - }, - ) - ownerMemberUser = await Factory.build( - 'user', - { - id: 'owner-member-user', - name: 'Owner Member TestUser', - }, - { - email: 'owner-member-user@example.org', - password: '1234', - }, - ) - secondOwnerMemberUser = await Factory.build( - 'user', - { - id: 'second-owner-member-user', - name: 'Second Owner Member TestUser', - }, - { - email: 'second-owner-member-user@example.org', - password: '1234', - }, - ) - // create groups - // public-group - authenticatedUser = await usualMemberUser.toJson() - await mutate({ - mutation: createGroupMutation, - variables: { - id: 'public-group', - name: 'The Best Group', - about: 'We will change the world!', - description: 'Some description' + descriptionAdditional100, - groupType: 'public', - actionRadius: 'regional', - categoryIds, - }, - }) - await mutate({ - mutation: joinGroupMutation, - variables: { - groupId: 'public-group', - userId: 'owner-of-closed-group', - }, - }) - await mutate({ - mutation: joinGroupMutation, - variables: { - groupId: 'public-group', - userId: 'owner-of-hidden-group', - }, - }) - // closed-group - authenticatedUser = await ownerMemberUser.toJson() - await mutate({ - mutation: createGroupMutation, - variables: { - id: 'closed-group', - name: 'Uninteresting Group', - about: 'We will change nothing!', - description: 'We love it like it is!?' + descriptionAdditional100, - groupType: 'closed', - actionRadius: 'national', - categoryIds, - }, - }) - // hidden-group - authenticatedUser = await adminMemberUser.toJson() - await mutate({ - mutation: createGroupMutation, - variables: { - id: 'hidden-group', - name: 'Investigative Journalism Group', - about: 'We will change all.', - description: 'We research …' + descriptionAdditional100, - groupType: 'hidden', - actionRadius: 'global', - categoryIds, - }, - }) - // 'JoinGroup' mutation does not work in hidden groups so we join them by 'ChangeGroupMemberRole' through the owner - 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', - }, - }) - 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', - }, - }) - - authenticatedUser = null + await seedComplexScenarioAndClearAuthentication() }) afterAll(async () => { @@ -2330,6 +2346,241 @@ describe('in mode', () => { }) }) + describe('LeaveGroup', () => { + beforeAll(async () => { + await seedComplexScenarioAndClearAuthentication() + // closed-group + authenticatedUser = await ownerMemberUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'closed-group', + userId: 'pending-member-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'closed-group', + userId: 'usual-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'closed-group', + userId: 'admin-member-user', + roleInGroup: 'admin', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'closed-group', + userId: 'second-owner-member-user', + roleInGroup: 'owner', + }, + }) + + authenticatedUser = null + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + groupId: 'not-existing-group', + userId: 'current-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + describe('in all group types', () => { + describe('here "closed-group" for example', () => { + const memberInGroup = async (userId, groupId) => { + const result = await query({ + query: groupMembersQuery, + variables: { + id: groupId, + }, + }) + return result.data && result.data.GroupMembers + ? !!result.data.GroupMembers.find((member) => member.id === userId) + : null + } + + beforeEach(async () => { + authenticatedUser = null + variables = { + groupId: 'closed-group', + } + }) + + describe('left by "pending-member-user"', () => { + it('has "null" as membership role, was in the group, and left the group', async () => { + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('pending-member-user', 'closed-group')).toBe(true) + authenticatedUser = await pendingMemberUser.toJson() + await expect( + mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'pending-member-user', + }, + }), + ).resolves.toMatchObject({ + data: { + LeaveGroup: { + id: 'pending-member-user', + myRoleInGroup: null, + }, + }, + errors: undefined, + }) + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('pending-member-user', 'closed-group')).toBe(false) + }) + }) + + describe('left by "usual-member-user"', () => { + it('has "null" as membership role, was in the group, and left the group', async () => { + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('usual-member-user', 'closed-group')).toBe(true) + authenticatedUser = await usualMemberUser.toJson() + await expect( + mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'usual-member-user', + }, + }), + ).resolves.toMatchObject({ + data: { + LeaveGroup: { + id: 'usual-member-user', + myRoleInGroup: null, + }, + }, + errors: undefined, + }) + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('usual-member-user', 'closed-group')).toBe(false) + }) + }) + + describe('left by "admin-member-user"', () => { + it('has "null" as membership role, was in the group, and left the group', async () => { + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('admin-member-user', 'closed-group')).toBe(true) + authenticatedUser = await adminMemberUser.toJson() + await expect( + mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'admin-member-user', + }, + }), + ).resolves.toMatchObject({ + data: { + LeaveGroup: { + id: 'admin-member-user', + myRoleInGroup: null, + }, + }, + errors: undefined, + }) + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('admin-member-user', 'closed-group')).toBe(false) + }) + }) + + describe('left by "owner-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await ownerMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'owner-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('left by "second-owner-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await secondOwnerMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'second-owner-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('left by "none-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await noMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'none-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('as "owner-member-user" try to leave member "usual-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await ownerMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'usual-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('as "usual-member-user" try to leave member "admin-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await usualMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'admin-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + }) + }) + describe('UpdateGroup', () => { beforeAll(async () => { await seedBasicsAndClearAuthentication() diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 8c881ef1d..d586f6b53 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -117,6 +117,11 @@ type Mutation { userId: ID! ): User + LeaveGroup( + groupId: ID! + userId: ID! + ): User + ChangeGroupMemberRole( groupId: ID! userId: ID! diff --git a/webapp/components/FollowButton.spec.js b/webapp/components/Button/FollowButton.spec.js similarity index 100% rename from webapp/components/FollowButton.spec.js rename to webapp/components/Button/FollowButton.spec.js diff --git a/webapp/components/FollowButton.vue b/webapp/components/Button/FollowButton.vue similarity index 99% rename from webapp/components/FollowButton.vue rename to webapp/components/Button/FollowButton.vue index 126a3891d..a0807ed6c 100644 --- a/webapp/components/FollowButton.vue +++ b/webapp/components/Button/FollowButton.vue @@ -19,7 +19,6 @@ import { followUserMutation, unfollowUserMutation } from '~/graphql/User' export default { name: 'HcFollowButton', - props: { followId: { type: String, default: null }, isFollowed: { type: Boolean, default: false }, diff --git a/webapp/components/Button/JoinLeaveButton.vue b/webapp/components/Button/JoinLeaveButton.vue new file mode 100644 index 000000000..993631065 --- /dev/null +++ b/webapp/components/Button/JoinLeaveButton.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/webapp/components/features/FollowList/FollowList.spec.js b/webapp/components/features/ProfileList/FollowList.spec.js similarity index 100% rename from webapp/components/features/FollowList/FollowList.spec.js rename to webapp/components/features/ProfileList/FollowList.spec.js diff --git a/webapp/components/features/FollowList/FollowList.story.js b/webapp/components/features/ProfileList/FollowList.story.js similarity index 100% rename from webapp/components/features/FollowList/FollowList.story.js rename to webapp/components/features/ProfileList/FollowList.story.js diff --git a/webapp/components/features/FollowList/FollowList.story.json b/webapp/components/features/ProfileList/FollowList.story.json similarity index 100% rename from webapp/components/features/FollowList/FollowList.story.json rename to webapp/components/features/ProfileList/FollowList.story.json diff --git a/webapp/components/features/ProfileList/FollowList.vue b/webapp/components/features/ProfileList/FollowList.vue new file mode 100644 index 000000000..7e626308b --- /dev/null +++ b/webapp/components/features/ProfileList/FollowList.vue @@ -0,0 +1,41 @@ + + + diff --git a/webapp/components/features/FollowList/FollowList.vue b/webapp/components/features/ProfileList/ProfileList.vue similarity index 70% rename from webapp/components/features/FollowList/FollowList.vue rename to webapp/components/features/ProfileList/ProfileList.vue index 114a610f1..29fdb2872 100644 --- a/webapp/components/features/FollowList/FollowList.vue +++ b/webapp/components/features/ProfileList/ProfileList.vue @@ -1,10 +1,10 @@