Merge branch 'master' into fix-deploy-branded

This commit is contained in:
Ulf Gebhardt 2023-03-08 23:07:56 +01:00 committed by GitHub
commit 9075798027
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 524 additions and 65 deletions

View File

@ -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 = () => {

View File

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

View File

@ -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
})
}

View File

@ -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!',
}),
]),
})
})
*/
})
})
})
})

View File

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

View File

@ -132,4 +132,9 @@ type Mutation {
userId: ID!
roleInGroup: GroupMemberRole!
): User
RemoveUserFromGroup(
groupId: ID!
userId: ID!
): User
}

View File

@ -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)
})
})
})
})
})

View File

@ -53,30 +53,33 @@
</ds-chip>
</template>
<template #edit="scope">
<ds-button v-if="scope.row.myRoleInGroup !== 'owner'" size="small" primary disabled>
<!-- TODO: implement removal of group members -->
<!-- :disabled="scope.row.myRoleInGroup === 'owner'"
-->
<base-button
v-if="scope.row.myRoleInGroup !== 'owner'"
size="small"
primary
@click="
isOpen = true
userId = scope.row.id
"
>
{{ $t('group.removeMemberButton') }}
</ds-button>
</base-button>
</template>
</ds-table>
<!-- TODO: implement removal of group members -->
<!-- TODO: change to ocelot.social modal -->
<!-- <ds-modal
v-if="isOpen"
v-model="isOpen"
:title="`${$t('group.removeMember')}`"
force
extended
:confirm-label="$t('group.removeMember')"
:cancel-label="$t('actions.cancel')"
@confirm="deleteMember(memberId)"
/> -->
<ds-modal
v-if="isOpen"
v-model="isOpen"
:title="`${$t('group.removeMember')}`"
force
extended
:confirm-label="$t('group.removeMember')"
:cancel-label="$t('actions.cancel')"
@confirm="removeUser()"
/>
</div>
</template>
<script>
import { changeGroupMemberRoleMutation } from '~/graphql/groups.js'
import { changeGroupMemberRoleMutation, removeUserFromGroupMutation } from '~/graphql/groups.js'
export default {
name: 'GroupMember',
@ -96,6 +99,8 @@ export default {
query: '',
searchProcess: null,
user: {},
isOpen: false,
userId: null,
}
},
computed: {
@ -139,6 +144,25 @@ export default {
this.$toast.error(error.message)
}
},
removeUser() {
this.$apollo
.mutate({
mutation: removeUserFromGroupMutation(),
variables: { groupId: this.groupId, userId: this.userId },
})
.then(({ data }) => {
this.$emit('loadGroupMembers')
this.$toast.success(
this.$t('group.memberRemoved', { name: data.RemoveUserFromGroup.slug }),
)
})
.catch((error) => {
this.$toast.error(error.message)
})
.finally(() => {
this.userId = null
})
},
},
}
</script>

View File

@ -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) => {

View File

@ -455,6 +455,7 @@
"message": "Eine Gruppe zu verlassen ist möglicherweise nicht rückgängig zu machen!<br>Gruppe <b>„{name}“</b> verlassen!",
"title": "Möchtest du wirklich die Gruppe verlassen?"
},
"memberRemoved": "Nutzer „{name}“ wurde aus der Gruppe entfernt!",
"members": "Mitglieder",
"membersAdministrationList": {
"avatar": "Avatar",

View File

@ -455,6 +455,7 @@
"message": "Leaving a group may be irreversible!<br>Leave group <b>“{name}”</b>!",
"title": "Do you really want to leave the group?"
},
"memberRemoved": "User “{name}” was removed from group!",
"members": "Members",
"membersAdministrationList": {
"avatar": "Avatar",