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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t('settings.deleteUserAccount.name') }}
+
+
+
+ {{ $t('settings.deleteUserAccount.accountDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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