User account deletion (#651)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/651
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2021-08-11 19:08:18 +00:00
parent 328c172d40
commit dc04c1b256
10 changed files with 246 additions and 28 deletions

View file

@ -36,6 +36,7 @@ import ContentAuth from './components/home/contentAuth'
import ContentLinkShare from './components/home/contentLinkShare' import ContentLinkShare from './components/home/contentLinkShare'
import ContentNoAuth from './components/home/contentNoAuth' import ContentNoAuth from './components/home/contentNoAuth'
import {setLanguage} from './i18n/setup' import {setLanguage} from './i18n/setup'
import AccountDeleteService from '@/services/accountDelete'
export default { export default {
name: 'app', name: 'app',
@ -51,6 +52,7 @@ export default {
this.setupOnlineStatus() this.setupOnlineStatus()
this.setupPasswortResetRedirect() this.setupPasswortResetRedirect()
this.setupEmailVerificationRedirect() this.setupEmailVerificationRedirect()
this.setupAccountDeletionVerification()
}, },
beforeCreate() { beforeCreate() {
this.$store.dispatch('config/update') this.$store.dispatch('config/update')
@ -95,6 +97,17 @@ export default {
this.$router.push({name: 'user.login'}) this.$router.push({name: 'user.login'})
} }
}, },
setupAccountDeletionVerification() {
if (typeof this.$route.query.accountDeletionConfirm !== 'undefined') {
const accountDeletionService = new AccountDeleteService()
accountDeletionService.confirm(this.$route.query.accountDeletionConfirm)
.then(() => {
this.success({message: this.$t('user.deletion.confirmSuccess')})
this.$store.dispatch('auth/refreshUserInfo')
})
.catch(e => this.error(e))
}
},
}, },
} }
</script> </script>

View file

@ -1,7 +1,6 @@
<template> <template>
<multiselect <multiselect
class="control is-expanded" class="control is-expanded"
v-focus
:loading="listSerivce.loading" :loading="listSerivce.loading"
:placeholder="$t('list.search')" :placeholder="$t('list.search')"
@search="findLists" @search="findLists"

View file

@ -0,0 +1,138 @@
<template>
<card :title="$t('user.deletion.title')" v-if="userDeletionEnabled">
<template v-if="deletionScheduledAt !== null">
<form @submit.prevent="cancelDeletion()">
<p>
{{
$t('user.deletion.scheduled', {
date: formatDateShort(deletionScheduledAt),
dateSince: formatDateSince(deletionScheduledAt),
})
}}
</p>
<p>
{{ $t('user.deletion.scheduledCancelText') }}
</p>
<div class="field">
<label class="label" for="currentPasswordAccountDelete">
{{ $t('user.settings.currentPassword') }}
</label>
<div class="control">
<input
class="input"
:class="{'is-danger': errPasswordRequired}"
id="currentPasswordAccountDelete"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="password"
@keyup="() => errPasswordRequired = password === ''"
ref="passwordInput"
/>
</div>
<p class="help is-danger" v-if="errPasswordRequired">
{{ $t('user.deletion.passwordRequired') }}
</p>
</div>
</form>
<x-button
:loading="accountDeleteService.loading"
@click="cancelDeletion()"
class="is-fullwidth mt-4">
{{ $t('user.deletion.scheduledCancelConfirm') }}
</x-button>
</template>
<template v-else>
<form @submit.prevent="deleteAccount()">
<p>
{{ $t('user.deletion.text1') }}
</p>
<p>
{{ $t('user.deletion.text2') }}
</p>
<div class="field">
<label class="label" for="currentPasswordAccountDelete">
{{ $t('user.settings.currentPassword') }}
</label>
<div class="control">
<input
class="input"
:class="{'is-danger': errPasswordRequired}"
id="currentPasswordAccountDelete"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="password"
@keyup="() => errPasswordRequired = password === ''"
ref="passwordInput"
/>
</div>
<p class="help is-danger" v-if="errPasswordRequired">
{{ $t('user.deletion.passwordRequired') }}
</p>
</div>
</form>
<x-button
:loading="accountDeleteService.loading"
@click="deleteAccount()"
class="is-fullwidth mt-4 is-danger">
{{ $t('user.deletion.confirm') }}
</x-button>
</template>
</card>
</template>
<script>
import AccountDeleteService from '../../../services/accountDelete'
import {mapState} from 'vuex'
import {parseDateOrNull} from '../../../helpers/parseDateOrNull'
export default {
name: 'user-settings-deletion',
data() {
return {
accountDeleteService: AccountDeleteService,
password: '',
errPasswordRequired: false,
}
},
created() {
this.accountDeleteService = new AccountDeleteService()
},
computed: mapState({
userDeletionEnabled: state => state.config.userDeletionEnabled,
deletionScheduledAt: state => parseDateOrNull(state.auth.info.deletionScheduledAt),
}),
methods: {
deleteAccount() {
if (this.password === '') {
this.errPasswordRequired = true
this.$refs.passwordInput.focus()
return
}
this.accountDeleteService.request(this.password)
.then(() => {
this.success({message: this.$t('user.deletion.requestSuccess')})
this.password = ''
})
.catch(e => this.error(e))
},
cancelDeletion() {
if (this.password === '') {
this.errPasswordRequired = true
this.$refs.passwordInput.focus()
return
}
this.accountDeleteService.cancel(this.password)
.then(() => {
this.success({message: this.$t('user.deletion.scheduledCancelSuccess')})
this.$store.dispatch('auth/refreshUserInfo')
this.password = ''
})
.catch(e => this.error(e))
},
},
}
</script>

View file

@ -96,6 +96,20 @@
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar status was updated successfully!",
"setSuccess": "The avatar has been set successfully!" "setSuccess": "The avatar has been set successfully!"
} }
},
"deletion": {
"title": "Delete your Vikunja Account",
"text1": "The deletion of your account is permanent and cannot be undone. We will delete all your namespaces, lists, tasks and everything associated with it.",
"text2": "To proceed, please enter your password. You will receive an email with further instructions.",
"confirm": "Delete my account",
"requestSuccess": "The request was successful. You'll receive an email with further instructions.",
"passwordRequired": "Please enter your password.",
"confirmSuccess": "You've successfully confirmed the deletion of your account. We will delete your account in three days.",
"scheduled": "We will delete your Vikunja account at {date} ({dateSince}).",
"scheduledCancel": "To cancel the deletion of your account, click here.",
"scheduledCancelText": "To cancel the deletion of your account, please enter your password below:",
"scheduledCancelConfirm": "Cancel the deletion of my account",
"scheduledCancelSuccess": "We will not delete your account."
} }
}, },
"list": { "list": {

View file

@ -72,6 +72,7 @@ export default class AbstractService {
this.http.defaults.headers.common['Authorization'] = `Bearer ${token}` this.http.defaults.headers.common['Authorization'] = `Bearer ${token}`
} }
if (paths) {
this.paths = { this.paths = {
create: paths.create !== undefined ? paths.create : '', create: paths.create !== undefined ? paths.create : '',
get: paths.get !== undefined ? paths.get : '', get: paths.get !== undefined ? paths.get : '',
@ -80,6 +81,7 @@ export default class AbstractService {
delete: paths.delete !== undefined ? paths.delete : '', delete: paths.delete !== undefined ? paths.delete : '',
} }
} }
}
/** /**
* Whether or not to use the create interceptor which processes a request payload into json * Whether or not to use the create interceptor which processes a request payload into json

View file

@ -0,0 +1,15 @@
import AbstractService from './abstractService'
export default class AccountDeleteService extends AbstractService {
request(password) {
return this.post('/user/deletion/request', {password: password})
}
confirm(token) {
return this.post('/user/deletion/confirm', {token: token})
}
cancel(password) {
return this.post('/user/deletion/cancel', {password: password})
}
}

View file

@ -181,6 +181,24 @@ export default {
ctx.commit('info', info) ctx.commit('info', info)
if (authenticated) { if (authenticated) {
ctx.dispatch('refreshUserInfo')
ctx.commit('authenticated', authenticated)
}
}
ctx.commit('authenticated', authenticated)
if (!authenticated) {
ctx.commit('info', null)
}
return Promise.resolve()
},
refreshUserInfo(ctx) {
const jwt = getToken()
if (!jwt) {
return
}
const HTTP = HTTPFactory() const HTTP = HTTPFactory()
// We're not returning the promise here to prevent blocking the initial ui render if the user is // We're not returning the promise here to prevent blocking the initial ui render if the user is
// accessing the site with a token in local storage // accessing the site with a token in local storage
@ -196,21 +214,11 @@ export default {
info.exp = ctx.state.info.exp info.exp = ctx.state.info.exp
ctx.commit('info', info) ctx.commit('info', info)
ctx.commit('authenticated', authenticated)
ctx.commit('lastUserRefresh') ctx.commit('lastUserRefresh')
}) })
.catch(e => { .catch(e => {
console.error('Error while refreshing user info:', e) console.error('Error while refreshing user info:', e)
}) })
}
}
ctx.commit('authenticated', authenticated)
if (!authenticated) {
ctx.commit('info', null)
}
return Promise.resolve()
}, },
// Renews the api token and saves it to local storage // Renews the api token and saves it to local storage
renewToken(ctx) { renewToken(ctx) {

View file

@ -23,6 +23,7 @@ export default {
privacyPolicyUrl: '', privacyPolicyUrl: '',
}, },
caldavEnabled: false, caldavEnabled: false,
userDeletionEnabled: true,
auth: { auth: {
local: { local: {
enabled: true, enabled: true,
@ -49,6 +50,7 @@ export default {
state.legal.imprintUrl = config.legal.imprint_url state.legal.imprintUrl = config.legal.imprint_url
state.legal.privacyPolicyUrl = config.legal.privacy_policy_url state.legal.privacyPolicyUrl = config.legal.privacy_policy_url
state.caldavEnabled = config.caldav_enabled state.caldavEnabled = config.caldav_enabled
state.userDeletionEnabled = config.user_deletion_enabled
const auth = objectToCamelCase(config.auth) const auth = objectToCamelCase(config.auth)
state.auth.local.enabled = auth.local.enabled state.auth.local.enabled = auth.local.enabled
state.auth.openidConnect.enabled = auth.openidConnect.enabled state.auth.openidConnect.enabled = auth.openidConnect.enabled

View file

@ -3,6 +3,17 @@
<h2> <h2>
{{ $t(`home.welcome${welcome}`, {username: userInfo.name !== '' ? userInfo.name : userInfo.username}) }}! {{ $t(`home.welcome${welcome}`, {username: userInfo.name !== '' ? userInfo.name : userInfo.username}) }}!
</h2> </h2>
<div class="notification is-danger" v-if="deletionScheduledAt !== null">
{{
$t('user.deletion.scheduled', {
date: formatDateShort(deletionScheduledAt),
dateSince: formatDateSince(deletionScheduledAt),
})
}}
<router-link :to="{name: 'user.settings', hash: '#deletion'}">
{{ $t('user.deletion.scheduledCancel') }}
</router-link>
</div>
<add-task <add-task
:listId="defaultListId" :listId="defaultListId"
@taskAdded="updateTaskList" @taskAdded="updateTaskList"
@ -51,6 +62,7 @@ import {getHistory} from '../modules/listHistory'
import ListCard from '@/components/list/partials/list-card.vue' import ListCard from '@/components/list/partials/list-card.vue'
import AddTask from '../components/tasks/add-task.vue' import AddTask from '../components/tasks/add-task.vue'
import {LOADING, LOADING_MODULE} from '../store/mutation-types' import {LOADING, LOADING_MODULE} from '../store/mutation-types'
import {parseDateOrNull} from '../helpers/parseDateOrNull'
export default { export default {
name: 'Home', name: 'Home',
@ -117,6 +129,7 @@ export default {
return state.namespaces.namespaces[0].lists.length > 0 return state.namespaces.namespaces[0].lists.length > 0
}, },
loading: state => state[LOADING] && state[LOADING_MODULE] === 'tasks', loading: state => state[LOADING] && state[LOADING_MODULE] === 'tasks',
deletionScheduledAt: state => parseDateOrNull(state.auth.info.deletionScheduledAt),
}), }),
}, },
methods: { methods: {

View file

@ -245,6 +245,9 @@
</x-button> </x-button>
</card> </card>
<!-- Account deletion -->
<user-settings-deletion id="deletion"/>
<!-- Caldav --> <!-- Caldav -->
<card v-if="caldavEnabled" :title="$t('user.settings.caldav.title')"> <card v-if="caldavEnabled" :title="$t('user.settings.caldav.title')">
<p> <p>
@ -289,6 +292,7 @@ import {mapState} from 'vuex'
import AvatarSettings from '../../components/user/avatar-settings.vue' import AvatarSettings from '../../components/user/avatar-settings.vue'
import copy from 'copy-to-clipboard' import copy from 'copy-to-clipboard'
import ListSearch from '@/components/tasks/partials/listSearch.vue' import ListSearch from '@/components/tasks/partials/listSearch.vue'
import UserSettingsDeletion from '../../components/user/settings/deletion'
export default { export default {
name: 'Settings', name: 'Settings',
@ -318,6 +322,7 @@ export default {
} }
}, },
components: { components: {
UserSettingsDeletion,
ListSearch, ListSearch,
AvatarSettings, AvatarSettings,
}, },
@ -342,6 +347,7 @@ export default {
}, },
mounted() { mounted() {
this.setTitle(this.$t('user.settings.title')) this.setTitle(this.$t('user.settings.title'))
this.anchorHashCheck()
}, },
computed: { computed: {
caldavUrl() { caldavUrl() {
@ -452,6 +458,14 @@ export default {
copy(text) { copy(text) {
copy(text) copy(text)
}, },
anchorHashCheck() {
if (window.location.hash === this.$route.hash) {
const el = document.getElementById(this.$route.hash.slice(1))
if (el) {
window.scrollTo(0, el.offsetTop)
}
}
},
}, },
} }
</script> </script>