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 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>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<multiselect
|
||||
class="control is-expanded"
|
||||
v-focus
|
||||
:loading="listSerivce.loading"
|
||||
:placeholder="$t('list.search')"
|
||||
@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!",
|
||||
"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": {
|
||||
|
|
|
@ -72,6 +72,7 @@ export default class AbstractService {
|
|||
this.http.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (paths) {
|
||||
this.paths = {
|
||||
create: paths.create !== undefined ? paths.create : '',
|
||||
get: paths.get !== undefined ? paths.get : '',
|
||||
|
@ -80,6 +81,7 @@ export default class AbstractService {
|
|||
delete: paths.delete !== undefined ? paths.delete : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
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()
|
||||
// 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
|
||||
|
@ -196,21 +214,11 @@ export default {
|
|||
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.commit('authenticated', authenticated)
|
||||
if (!authenticated) {
|
||||
ctx.commit('info', null)
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
},
|
||||
// Renews the api token and saves it to local storage
|
||||
renewToken(ctx) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue