From dc04c1b2562f778e635c892197ba4de6287fd5fe Mon Sep 17 00:00:00 2001 From: konrad Date: Wed, 11 Aug 2021 19:08:18 +0000 Subject: [PATCH] User account deletion (#651) Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/651 Co-authored-by: konrad Co-committed-by: konrad --- src/App.vue | 13 ++ src/components/tasks/partials/listSearch.vue | 1 - src/components/user/settings/deletion.vue | 138 +++++++++++++++++++ src/i18n/lang/en.json | 14 ++ src/services/abstractService.js | 14 +- src/services/accountDelete.js | 15 ++ src/store/modules/auth.js | 50 ++++--- src/store/modules/config.js | 2 + src/views/Home.vue | 13 ++ src/views/user/Settings.vue | 14 ++ 10 files changed, 246 insertions(+), 28 deletions(-) create mode 100644 src/components/user/settings/deletion.vue create mode 100644 src/services/accountDelete.js diff --git a/src/App.vue b/src/App.vue index 4525a079d..87101e174 100644 --- a/src/App.vue +++ b/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)) + } + }, }, } diff --git a/src/components/tasks/partials/listSearch.vue b/src/components/tasks/partials/listSearch.vue index 3cb83e890..1424babf6 100644 --- a/src/components/tasks/partials/listSearch.vue +++ b/src/components/tasks/partials/listSearch.vue @@ -1,7 +1,6 @@ + + diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index 161edaf13..3cfc31e7c 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -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": { diff --git a/src/services/abstractService.js b/src/services/abstractService.js index 86a8abdaa..7855421c9 100644 --- a/src/services/abstractService.js +++ b/src/services/abstractService.js @@ -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 : '', + } } } diff --git a/src/services/accountDelete.js b/src/services/accountDelete.js new file mode 100644 index 000000000..c4f443cfe --- /dev/null +++ b/src/services/accountDelete.js @@ -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}) + } +} \ No newline at end of file diff --git a/src/store/modules/auth.js b/src/store/modules/auth.js index 767ee2b1b..2dfbd5093 100644 --- a/src/store/modules/auth.js +++ b/src/store/modules/auth.js @@ -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 diff --git a/src/store/modules/config.js b/src/store/modules/config.js index 1c0b5b34e..60017fedb 100644 --- a/src/store/modules/config.js +++ b/src/store/modules/config.js @@ -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 diff --git a/src/views/Home.vue b/src/views/Home.vue index 25d874aa9..50240f80e 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -3,6 +3,17 @@

{{ $t(`home.welcome${welcome}`, {username: userInfo.name !== '' ? userInfo.name : userInfo.username}) }}!

+
+ {{ + $t('user.deletion.scheduled', { + date: formatDateShort(deletionScheduledAt), + dateSince: formatDateSince(deletionScheduledAt), + }) + }} + + {{ $t('user.deletion.scheduledCancel') }} + +
0 }, loading: state => state[LOADING] && state[LOADING_MODULE] === 'tasks', + deletionScheduledAt: state => parseDateOrNull(state.auth.info.deletionScheduledAt), }), }, methods: { diff --git a/src/views/user/Settings.vue b/src/views/user/Settings.vue index a3f4bc2a4..a66ac6499 100644 --- a/src/views/user/Settings.vue +++ b/src/views/user/Settings.vue @@ -245,6 +245,9 @@ + + +

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