Merge pull request #740 from Human-Connection/404-delete-user-account-and-data

Delete my User Account functionality
This commit is contained in:
mattwr18 2019-06-14 13:45:30 -03:00 committed by GitHub
commit b5fb7cb34b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 618 additions and 44 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,224 @@
<template>
<div>
<ds-card hover>
<ds-space />
<ds-container>
<ds-flex>
<ds-flex-item :width="{ base: '22%', sm: '12%', md: '12%', lg: '8%' }">
<ds-icon name="warning" size="xxx-large" class="delete-warning-icon" />
</ds-flex-item>
<ds-flex-item :width="{ base: '78%', sm: '88%', md: '88%', lg: '92%' }">
<ds-heading>{{ $t('settings.deleteUserAccount.name') }}</ds-heading>
</ds-flex-item>
<ds-space />
<ds-heading tag="h4">
{{ $t('settings.deleteUserAccount.accountDescription') }}
</ds-heading>
</ds-flex>
</ds-container>
<ds-space />
<ds-container>
<transition name="slide-up">
<div v-if="deleteEnabled">
<label v-if="currentUser.contributionsCount" class="checkbox-container">
<input type="checkbox" v-model="deleteContributions" />
<span class="checkmark"></span>
{{
$t('settings.deleteUserAccount.contributionsCount', {
count: currentUser.contributionsCount,
})
}}
</label>
<ds-space margin-bottom="small" />
<label v-if="currentUser.commentsCount" class="checkbox-container">
<input type="checkbox" v-model="deleteComments" />
<span class="checkmark"></span>
{{
$t('settings.deleteUserAccount.commentsCount', {
count: currentUser.commentsCount,
})
}}
</label>
<ds-space margin-bottom="small" />
<ds-section id="delete-user-account-warning">
<div v-html="$t('settings.deleteUserAccount.accountWarning')"></div>
</ds-section>
</div>
</transition>
</ds-container>
<template slot="footer" class="delete-data-footer">
<ds-container>
<div
class="delete-input-label"
v-html="$t('settings.deleteUserAccount.pleaseConfirm', { confirm: currentUser.name })"
></div>
<ds-space margin-bottom="xx-small" />
<ds-flex :gutter="{ base: 'xx-small', md: 'small', lg: 'large' }">
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '100%', lg: 1.75 }">
<ds-input
v-model="enableDeletionValue"
@input="enableDeletion"
class="enable-deletion-input"
/>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '100%', lg: 1 }">
<ds-button icon="trash" danger :disabled="!deleteEnabled" @click="handleSubmit">
{{ $t('settings.deleteUserAccount.name') }}
</ds-button>
</ds-flex-item>
</ds-flex>
</ds-container>
</template>
</ds-card>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import gql from 'graphql-tag'
export default {
name: 'DeleteData',
data() {
return {
deleteContributions: false,
deleteComments: false,
deleteEnabled: false,
}
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
},
methods: {
...mapActions({
logout: 'auth/logout',
}),
enableDeletion() {
if (this.enableDeletionValue === this.currentUser.name) {
this.deleteEnabled = true
}
},
handleSubmit() {
let resourceArgs = []
if (this.deleteContributions) {
resourceArgs.push('Post')
}
if (this.deleteComments) {
resourceArgs.push('Comment')
}
this.$apollo
.mutate({
mutation: gql`
mutation($id: ID!, $resource: [String]) {
DeleteUser(id: $id, resource: $resource) {
id
}
}
`,
variables: { id: this.currentUser.id, resource: resourceArgs },
})
.then(() => {
this.$toast.success(this.$t('settings.deleteUserAccount.success'))
this.logout()
this.$router.history.push('/')
})
.catch(error => {
this.$toast.error(error.message)
})
},
},
}
</script>
<style lang="scss">
.delete-warning-icon {
color: $color-danger;
}
.checkbox-container {
display: block;
position: relative;
padding-left: 35px;
cursor: pointer;
font-size: $font-size-large;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.checkbox-container input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 20px;
width: 20px;
border: 2px solid $background-color-inverse-softer;
background-color: $background-color-base;
border-radius: $border-radius-x-large;
}
.checkbox-container:hover input ~ .checkmark {
background-color: $background-color-softest;
}
.checkbox-container input:checked ~ .checkmark {
background-color: $background-color-danger-active;
}
.checkmark:after {
content: '';
position: absolute;
display: none;
}
.checkbox-container input:checked ~ .checkmark:after {
display: block;
}
.checkbox-container .checkmark:after {
left: 6px;
top: 3px;
width: 5px;
height: 10px;
border: solid $background-color-base;
border-width: 0 $border-size-large $border-size-large 0;
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.enable-deletion-input input:focus {
border-color: $border-color-danger;
}
.delete-input-label {
font-size: $font-size-base;
}
b.is-danger {
color: $text-color-danger;
}
.delete-data-footer {
border-top: $border-size-base solid $border-color-softest;
background-color: $background-color-danger-inverse;
}
#delete-user-account-warning {
background-color: $background-color-danger-inverse;
border-left: $border-size-x-large solid $background-color-danger-active;
color: $text-color-danger;
margin-left: 0px;
margin-right: 0px;
border-radius: $border-radius-x-large;
}
</style>

View File

@ -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 <b>WEDER VERWALTEN NOCH WIEDERHERSTELLEN!</b>",
"success": "Konto erfolgreich gelöscht",
"pleaseConfirm": "<b class='is-danger'>Zerstörerische Aktion!</b> Gib <b>{confirm}</b> ein, um zu bestätigen."
},
"organizations": {
"name": "Meine Organisationen"

View File

@ -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 <b>CAN'T MANAGE</b> and <b>CAN'T RECOVER</b> your Account, Posts, or Comments after deleting your account!",
"success": "Account successfully deleted",
"pleaseConfirm": "<b class='is-danger'>Destructive action!</b> Type <b>{confirm}</b> to confirm"
},
"organizations": {
"name": "My Organizations"

View File

@ -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)"
/>
</no-ssr>
<ds-space margin-bottom="small" />
<ds-heading tag="h3" no-margin>
{{ post.title }}
</ds-heading>
<ds-heading tag="h3" no-margin>{{ post.title }}</ds-heading>
<ds-space margin-bottom="small" />
<!-- Content -->
<!-- eslint-disable vue/no-v-html -->

View File

@ -228,7 +228,6 @@
<script>
import uniqBy from 'lodash/uniqBy'
import User from '~/components/User'
import HcPostCard from '~/components/PostCard'
import HcFollowButton from '~/components/FollowButton.vue'
@ -240,10 +239,8 @@ import ContentMenu from '~/components/ContentMenu'
import HcUpload from '~/components/Upload'
import HcAvatar from '~/components/Avatar/Avatar.vue'
import PostMutationHelpers from '~/mixins/PostMutationHelpers'
import PostQuery from '~/graphql/UserProfile/Post.js'
import UserQuery from '~/graphql/UserProfile/User.js'
const tabToFilterMapping = ({ tab, id }) => {
return {
post: { author: { id } },
@ -251,7 +248,6 @@ const tabToFilterMapping = ({ tab, id }) => {
shout: { shoutedBy_some: { id } },
}[tab]
}
export default {
components: {
User,
@ -397,7 +393,6 @@ export default {
.pointer {
cursor: pointer;
}
.Tab {
border-collapse: collapse;
padding-bottom: 5px;
@ -405,7 +400,6 @@ export default {
.Tab:hover {
border-bottom: 2px solid #c9c6ce;
}
.Tabs {
position: relative;
background-color: #fff;
@ -441,14 +435,12 @@ export default {
transition: left 0.25s;
}
}
.profile-avatar.ds-avatar {
display: block;
margin: auto;
margin-top: -60px;
border: #fff 5px solid;
}
.page-name-profile-id-slug {
.ds-flex-item:first-child .content-menu {
position: absolute;
@ -456,17 +448,14 @@ export default {
right: $space-x-small;
}
}
.profile-top-navigation {
position: sticky;
top: 53px;
z-index: 2;
}
.ds-tab-nav {
.ds-card-content {
padding: 0 !important;
.ds-tab-nav-item {
&.ds-tab-nav-item-active {
border-bottom: 3px solid #17b53f;

View File

@ -1,8 +1,6 @@
<template>
<div>
<ds-heading tag="h1">
{{ $t('settings.name') }}
</ds-heading>
<ds-heading tag="h1">{{ $t('settings.name') }}</ds-heading>
<ds-flex gutter="small">
<ds-flex-item :width="{ base: '100%', md: '200px' }">
<ds-menu :routes="routes" :is-exact="() => true" />
@ -33,6 +31,10 @@ export default {
name: this.$t('settings.social-media.name'),
path: `/settings/my-social-media`,
},
{
name: this.$t('settings.deleteUserAccount.name'),
path: `/settings/delete-account`,
},
// TODO implement
/* {
name: this.$t('settings.invites.name'),
@ -44,10 +46,6 @@ export default {
path: `/settings/data-download`
}, */
// TODO implement
/* {
name: this.$t('settings.delete.name'),
path: `/settings/delete-account`
}, */
// TODO implement
/* {
name: this.$t('settings.organizations.name'),

View File

@ -1,15 +1,13 @@
<template>
<ds-card :header="$t('settings.delete.name')">
<hc-empty icon="tasks" message="Coming Soon…" />
</ds-card>
<delete-data />
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import DeleteData from '~/components/DeleteData/DeleteData.vue'
export default {
components: {
HcEmpty,
DeleteData,
},
}
</script>

View File

@ -79,6 +79,8 @@ export const actions = {
role
about
locationName
contributionsCount
commentsCount
socialMedia {
id
url