diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 1f0ffd79a..10b777748 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -93,6 +93,12 @@ const isAuthor = rule({ return authorId === user.id }) +const isDeletingOwnAccount = rule({ + cache: 'no_cache', +})(async (parent, args, context, info) => { + return context.user.id === args.id +}) + // Permissions const permissions = shield( { @@ -140,6 +146,7 @@ const permissions = shield( disable: isModerator, CreateComment: isAuthenticated, DeleteComment: isAuthor, + DeleteUser: isDeletingOwnAccount, }, User: { email: isMyOwn, diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 53bf0967e..c5c3701b5 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -11,5 +11,27 @@ export default { params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' }) return neo4jgraphql(object, params, context, resolveInfo, false) }, + DeleteUser: async (object, params, context, resolveInfo) => { + const { resource } = params + const session = context.driver.session() + + if (resource && resource.length) { + await Promise.all( + resource.map(async node => { + await session.run( + ` + MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId}) + SET resource.deleted = true + RETURN author`, + { + userId: context.user.id, + }, + ) + }), + ) + session.close() + } + return neo4jgraphql(object, params, context, resolveInfo, false) + }, }, } diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index 6334272dd..352d38eaa 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -1,6 +1,7 @@ import { GraphQLClient } from 'graphql-request' import { login, host } from '../../jest/helpers' import Factory from '../../seed/factories' +import gql from 'graphql-tag' const factory = Factory() let client @@ -137,4 +138,141 @@ describe('users', () => { }) }) }) + + describe('DeleteUser', () => { + let deleteUserVariables + let asAuthor + const deleteUserMutation = gql` + mutation($id: ID!, $resource: [String]) { + DeleteUser(id: $id, resource: $resource) { + id + contributions { + id + deleted + } + comments { + id + deleted + } + } + } + ` + beforeEach(async () => { + asAuthor = await factory.create('User', { + email: 'test@example.org', + password: '1234', + id: 'u343', + }) + await factory.create('User', { + email: 'friendsAccount@example.org', + password: '1234', + id: 'u565', + }) + deleteUserVariables = { id: 'u343', resource: [] } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow( + 'Not Authorised', + ) + }) + }) + + describe('authenticated', () => { + let headers + beforeEach(async () => { + headers = await login({ + email: 'test@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { headers }) + }) + + describe("attempting to delete another user's account", () => { + it('throws an authorization error', async () => { + deleteUserVariables = { id: 'u565' } + await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow( + 'Not Authorised', + ) + }) + }) + + describe('attempting to delete my own account', () => { + let expectedResponse + beforeEach(async () => { + await asAuthor.authenticateAs({ + email: 'test@example.org', + password: '1234', + }) + await asAuthor.create('Post', { + id: 'p139', + content: 'Post by user u343', + }) + await asAuthor.create('Comment', { + id: 'c155', + postId: 'p139', + content: 'Comment by user u343', + }) + expectedResponse = { + DeleteUser: { + id: 'u343', + contributions: [{ id: 'p139', deleted: false }], + comments: [{ id: 'c155', deleted: false }], + }, + } + }) + it("deletes my account, but doesn't delete posts or comments by default", async () => { + await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( + expectedResponse, + ) + }) + + describe("deletes a user's", () => { + it('posts on request', async () => { + deleteUserVariables = { id: 'u343', resource: ['Post'] } + expectedResponse = { + DeleteUser: { + id: 'u343', + contributions: [{ id: 'p139', deleted: true }], + comments: [{ id: 'c155', deleted: false }], + }, + } + await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( + expectedResponse, + ) + }) + + it('comments on request', async () => { + deleteUserVariables = { id: 'u343', resource: ['Comment'] } + expectedResponse = { + DeleteUser: { + id: 'u343', + contributions: [{ id: 'p139', deleted: false }], + comments: [{ id: 'c155', deleted: true }], + }, + } + await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( + expectedResponse, + ) + }) + + it('posts and comments on request', async () => { + deleteUserVariables = { id: 'u343', resource: ['Post', 'Comment'] } + expectedResponse = { + DeleteUser: { + id: 'u343', + contributions: [{ id: 'p139', deleted: true }], + comments: [{ id: 'c155', deleted: true }], + }, + } + await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( + expectedResponse, + ) + }) + }) + }) + }) + }) }) diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index ab8b25399..2a8be9e09 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -4,8 +4,9 @@ type Query { currentUser: User # Get the latest Network Statistics statistics: Statistics! - findPosts(filter: String!, limit: Int = 10): [Post]! @cypher( - statement: """ + findPosts(filter: String!, limit: Int = 10): [Post]! + @cypher( + statement: """ CALL db.index.fulltext.queryNodes('full_text_search', $filter) YIELD node as post, score MATCH (post)<-[:WROTE]-(user:User) @@ -14,8 +15,8 @@ type Query { AND NOT post.deleted = true AND NOT post.disabled = true RETURN post LIMIT $limit - """ - ) + """ + ) CommentByPost(postId: ID!): [Comment]! } @@ -23,7 +24,7 @@ type Mutation { # Get a JWT Token for the given Email and password login(email: String!, password: String!): String! signup(email: String!, password: String!): Boolean! - changePassword(oldPassword:String!, newPassword: String!): String! + changePassword(oldPassword: String!, newPassword: String!): String! report(id: ID!, description: String): Report disable(id: ID!): ID enable(id: ID!): ID @@ -37,6 +38,7 @@ type Mutation { follow(id: ID!, type: FollowTypeEnum): Boolean! # Unfollow the given Type and ID unfollow(id: ID!, type: FollowTypeEnum): Boolean! + DeleteUser(id: ID!, resource: [String]): User } type Statistics { @@ -53,7 +55,7 @@ type Statistics { type Notification { id: ID! - read: Boolean, + read: Boolean user: User @relation(name: "NOTIFIED", direction: "OUT") post: Post @relation(name: "NOTIFIED", direction: "IN") createdAt: String @@ -80,7 +82,8 @@ type Report { id: ID! submitter: User @relation(name: "REPORTED", direction: "IN") description: String - type: String! @cypher(statement: "MATCH (resource)<-[:REPORTED]-(this) RETURN labels(resource)[0]") + type: String! + @cypher(statement: "MATCH (resource)<-[:REPORTED]-(this) RETURN labels(resource)[0]") createdAt: String comment: Comment @relation(name: "REPORTED", direction: "OUT") post: Post @relation(name: "REPORTED", direction: "OUT") @@ -131,4 +134,3 @@ type SocialMedia { url: String ownedBy: [User]! @relation(name: "OWNED", direction: "IN") } - diff --git a/webapp/assets/styles/main.scss b/webapp/assets/styles/main.scss index db967e973..560249b4a 100644 --- a/webapp/assets/styles/main.scss +++ b/webapp/assets/styles/main.scss @@ -10,7 +10,7 @@ $easeOut: cubic-bezier(0.19, 1, 0.22, 1); &::before { @include border-radius($border-radius-x-large); box-shadow: inset 0 0 0 5px $color-danger; - content: ""; + content: ''; display: block; position: absolute; width: 100%; @@ -102,10 +102,10 @@ hr { height: 1px !important; } -[class$=menu-trigger] { +[class$='menu-trigger'] { user-select: none; } -[class$=menu-popover] { +[class$='menu-popover'] { display: inline-block; nav { @@ -145,10 +145,11 @@ hr { } } -[class$="menu-popover"] { +[class$='menu-popover'] { min-width: 130px; - a, button { + a, + button { display: flex; align-content: center; align-items: center; diff --git a/webapp/components/DeleteData/DeleteData.spec.js b/webapp/components/DeleteData/DeleteData.spec.js new file mode 100644 index 000000000..139316ed2 --- /dev/null +++ b/webapp/components/DeleteData/DeleteData.spec.js @@ -0,0 +1,183 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import DeleteData from './DeleteData.vue' +import Styleguide from '@human-connection/styleguide' +import Vuex from 'vuex' + +const localVue = createLocalVue() + +localVue.use(Vuex) +localVue.use(Styleguide) + +describe('DeleteData.vue', () => { + let mocks + let wrapper + let getters + let actions + let deleteAccountBtn + let enableDeletionInput + let enableContributionDeletionCheckbox + let enableCommentDeletionCheckbox + const deleteAccountName = 'Delete MyAccount' + const deleteContributionsMessage = 'Delete my 2 posts' + const deleteCommentsMessage = 'Delete my 3 comments' + + beforeEach(() => { + mocks = { + $t: jest.fn(), + $apollo: { + mutate: jest + .fn() + .mockResolvedValueOnce({ + data: { + DeleteData: { + id: 'u343', + }, + }, + }) + .mockRejectedValue({ message: 'Not authorised!' }), + }, + $toast: { + error: jest.fn(), + success: jest.fn(), + }, + $router: { + history: { + push: jest.fn(), + }, + }, + } + getters = { + 'auth/user': () => { + return { id: 'u343', name: deleteAccountName, contributionsCount: 2, commentsCount: 3 } + }, + } + actions = { 'auth/logout': jest.fn() } + }) + + describe('mount', () => { + const Wrapper = () => { + const store = new Vuex.Store({ + getters, + actions, + }) + return mount(DeleteData, { mocks, localVue, store }) + } + + beforeEach(() => { + wrapper = Wrapper() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('defaults to deleteContributions to false', () => { + expect(wrapper.vm.deleteContributions).toEqual(false) + }) + + it('defaults to deleteComments to false', () => { + expect(wrapper.vm.deleteComments).toEqual(false) + }) + + it('defaults to deleteEnabled to false', () => { + expect(wrapper.vm.deleteEnabled).toEqual(false) + }) + + it('does not call the delete user mutation if deleteEnabled is false', () => { + deleteAccountBtn = wrapper.find('.ds-button-danger') + deleteAccountBtn.trigger('click') + expect(mocks.$apollo.mutate).not.toHaveBeenCalled() + }) + + describe('calls the delete user mutation', () => { + beforeEach(() => { + enableDeletionInput = wrapper.find('.enable-deletion-input input') + enableDeletionInput.setValue(deleteAccountName) + deleteAccountBtn = wrapper.find('.ds-button-danger') + }) + + it('if deleteEnabled is true and only deletes user by default', () => { + deleteAccountBtn.trigger('click') + expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + id: 'u343', + resource: [], + }, + }), + ) + }) + + it("deletes a user's posts if requested", () => { + mocks.$t.mockImplementation(() => deleteContributionsMessage) + enableContributionDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(0) + enableContributionDeletionCheckbox.trigger('click') + deleteAccountBtn.trigger('click') + expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + id: 'u343', + resource: ['Post'], + }, + }), + ) + }) + + it("deletes a user's comments if requested", () => { + mocks.$t.mockImplementation(() => deleteCommentsMessage) + enableCommentDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(1) + enableCommentDeletionCheckbox.trigger('click') + deleteAccountBtn.trigger('click') + expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + id: 'u343', + resource: ['Comment'], + }, + }), + ) + }) + + it("deletes a user's posts and comments if requested", () => { + mocks.$t.mockImplementation(() => deleteContributionsMessage) + enableContributionDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(0) + enableContributionDeletionCheckbox.trigger('click') + mocks.$t.mockImplementation(() => deleteCommentsMessage) + enableCommentDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(1) + enableCommentDeletionCheckbox.trigger('click') + deleteAccountBtn.trigger('click') + expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + id: 'u343', + resource: ['Post', 'Comment'], + }, + }), + ) + }) + + it('shows a success toaster after successful mutation', async () => { + await deleteAccountBtn.trigger('click') + expect(mocks.$toast.success).toHaveBeenCalledTimes(1) + }) + + it('redirect the user to the homepage', async () => { + await deleteAccountBtn.trigger('click') + expect(mocks.$router.history.push).toHaveBeenCalledWith('/') + }) + }) + + describe('error handling', () => { + it('shows an error toaster when the mutation rejects', async () => { + enableDeletionInput = wrapper.find('.enable-deletion-input input') + enableDeletionInput.setValue(deleteAccountName) + deleteAccountBtn = wrapper.find('.ds-button-danger') + await deleteAccountBtn.trigger('click') + // second submission causes mutation to reject + await deleteAccountBtn.trigger('click') + await mocks.$apollo.mutate + expect(mocks.$toast.error).toHaveBeenCalledWith('Not authorised!') + }) + }) + }) +}) diff --git a/webapp/components/DeleteData/DeleteData.vue b/webapp/components/DeleteData/DeleteData.vue new file mode 100644 index 000000000..cac548fa4 --- /dev/null +++ b/webapp/components/DeleteData/DeleteData.vue @@ -0,0 +1,224 @@ + + + diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 6f7bb3299..c17bfc96b 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -82,8 +82,14 @@ "download": { "name": "Daten herunterladen" }, - "delete": { - "name": "Konto löschen" + "deleteUserAccount": { + "name": "Daten löschen", + "contributionsCount": "Meine {count} Beiträge löschen", + "commentsCount": "Meine {count} Kommentare löschen", + "accountDescription": "Sei dir bewusst, dass deine Beiträge und Kommentare für unsere Community wichtig sind. Wenn du sie trotzdem löschen möchtest, musst du sie unten markieren.", + "accountWarning": "Dein Konto, deine Beiträge oder Kommentare kannst du nach dem Löschen WEDER VERWALTEN NOCH WIEDERHERSTELLEN!", + "success": "Konto erfolgreich gelöscht", + "pleaseConfirm": "Zerstörerische Aktion! Gib {confirm} ein, um zu bestätigen." }, "organizations": { "name": "Meine Organisationen" diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 8544498b7..22202ebde 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -82,8 +82,14 @@ "download": { "name": "Download Data" }, - "delete": { - "name": "Delete Account" + "deleteUserAccount": { + "name": "Delete Data", + "contributionsCount": "Delete my {count} posts", + "commentsCount": "Delete my {count} comments", + "accountDescription": "Be aware that your Post and Comments are important to our community. If you still choose to delete them, you have to mark them below.", + "accountWarning": "You CAN'T MANAGE and CAN'T RECOVER your Account, Posts, or Comments after deleting your account!", + "success": "Account successfully deleted", + "pleaseConfirm": "Destructive action! Type {confirm} to confirm" }, "organizations": { "name": "My Organizations" diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue index 81f05c36c..d2113dff7 100644 --- a/webapp/pages/post/_id/_slug/index.vue +++ b/webapp/pages/post/_id/_slug/index.vue @@ -13,13 +13,11 @@ resource-type="contribution" :resource="post" :callbacks="{ confirm: () => deletePostCallback('page'), cancel: null }" - :is-owner="isAuthor(post.author.id)" + :is-owner="isAuthor(post.author ? post.author.id : null)" /> - - {{ post.title }} - + {{ post.title }} diff --git a/webapp/pages/profile/_id/_slug.vue b/webapp/pages/profile/_id/_slug.vue index 60f539ac7..b9b462454 100644 --- a/webapp/pages/profile/_id/_slug.vue +++ b/webapp/pages/profile/_id/_slug.vue @@ -228,7 +228,6 @@ diff --git a/webapp/store/auth.js b/webapp/store/auth.js index 87de84d9a..6185a38cf 100644 --- a/webapp/store/auth.js +++ b/webapp/store/auth.js @@ -79,6 +79,8 @@ export const actions = { role about locationName + contributionsCount + commentsCount socialMedia { id url