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 ContentNoAuth from './components/home/contentNoAuth'
import {setLanguage} from './i18n/setup'
import AccountDeleteService from '@/services/accountDelete'
export default {
name: 'app',
@ -51,6 +52,7 @@ export default {
this.setupOnlineStatus()
this.setupPasswortResetRedirect()
this.setupEmailVerificationRedirect()
this.setupAccountDeletionVerification()
},
beforeCreate() {
this.$store.dispatch('config/update')
@ -95,6 +97,17 @@ export default {
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>

View file

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

View file

@ -72,12 +72,14 @@ export default class AbstractService {
this.http.defaults.headers.common['Authorization'] = `Bearer ${token}`
}
this.paths = {
create: paths.create !== undefined ? paths.create : '',
get: paths.get !== undefined ? paths.get : '',
getAll: paths.getAll !== undefined ? paths.getAll : '',
update: paths.update !== undefined ? paths.update : '',
delete: paths.delete !== undefined ? paths.delete : '',
if (paths) {
this.paths = {
create: paths.create !== undefined ? paths.create : '',
get: paths.get !== undefined ? paths.get : '',
getAll: paths.getAll !== undefined ? paths.getAll : '',
update: paths.update !== undefined ? paths.update : '',
delete: paths.delete !== undefined ? paths.delete : '',
}
}
}

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,27 +181,8 @@ export default {
ctx.commit('info', info)
if (authenticated) {
const HTTP = HTTPFactory()
// 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
HTTP.get('user', {
headers: {
Authorization: `Bearer ${jwt}`,
},
})
.then(r => {
const info = new UserModel(r.data)
info.type = ctx.state.info.type
info.email = ctx.state.info.email
info.exp = ctx.state.info.exp
ctx.commit('info', info)
ctx.commit('authenticated', authenticated)
ctx.commit('lastUserRefresh')
})
.catch(e => {
console.error('Error while refreshing user info:', e)
})
ctx.dispatch('refreshUserInfo')
ctx.commit('authenticated', authenticated)
}
}
@ -212,6 +193,33 @@ export default {
return Promise.resolve()
},
refreshUserInfo(ctx) {
const jwt = getToken()
if (!jwt) {
return
}
const HTTP = HTTPFactory()
// 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
HTTP.get('user', {
headers: {
Authorization: `Bearer ${jwt}`,
},
})
.then(r => {
const info = new UserModel(r.data)
info.type = ctx.state.info.type
info.email = ctx.state.info.email
info.exp = ctx.state.info.exp
ctx.commit('info', info)
ctx.commit('lastUserRefresh')
})
.catch(e => {
console.error('Error while refreshing user info:', e)
})
},
// Renews the api token and saves it to local storage
renewToken(ctx) {
// Timeout to avoid race conditions when authenticated as a user (=auth token in localStorage) and as a

View file

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

View file

@ -3,6 +3,17 @@
<h2>
{{ $t(`home.welcome${welcome}`, {username: userInfo.name !== '' ? userInfo.name : userInfo.username}) }}!
</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
:listId="defaultListId"
@taskAdded="updateTaskList"
@ -51,6 +62,7 @@ import {getHistory} from '../modules/listHistory'
import ListCard from '@/components/list/partials/list-card.vue'
import AddTask from '../components/tasks/add-task.vue'
import {LOADING, LOADING_MODULE} from '../store/mutation-types'
import {parseDateOrNull} from '../helpers/parseDateOrNull'
export default {
name: 'Home',
@ -117,6 +129,7 @@ export default {
return state.namespaces.namespaces[0].lists.length > 0
},
loading: state => state[LOADING] && state[LOADING_MODULE] === 'tasks',
deletionScheduledAt: state => parseDateOrNull(state.auth.info.deletionScheduledAt),
}),
},
methods: {

View file

@ -245,6 +245,9 @@
</x-button>
</card>
<!-- Account deletion -->
<user-settings-deletion id="deletion"/>
<!-- Caldav -->
<card v-if="caldavEnabled" :title="$t('user.settings.caldav.title')">
<p>
@ -289,6 +292,7 @@ import {mapState} from 'vuex'
import AvatarSettings from '../../components/user/avatar-settings.vue'
import copy from 'copy-to-clipboard'
import ListSearch from '@/components/tasks/partials/listSearch.vue'
import UserSettingsDeletion from '../../components/user/settings/deletion'
export default {
name: 'Settings',
@ -318,6 +322,7 @@ export default {
}
},
components: {
UserSettingsDeletion,
ListSearch,
AvatarSettings,
},
@ -342,6 +347,7 @@ export default {
},
mounted() {
this.setTitle(this.$t('user.settings.title'))
this.anchorHashCheck()
},
computed: {
caldavUrl() {
@ -452,6 +458,14 @@ export default {
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>