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:
parent
328c172d40
commit
dc04c1b256
10 changed files with 246 additions and 28 deletions
13
src/App.vue
13
src/App.vue
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
138
src/components/user/settings/deletion.vue
Normal file
138
src/components/user/settings/deletion.vue
Normal 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>
|
|
@ -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": {
|
||||||
|
|
|
@ -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
|
||||||
|
|
15
src/services/accountDelete.js
Normal file
15
src/services/accountDelete.js
Normal 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})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue