feat: move user settings to multiple components (#889)
Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/889 Co-authored-by: konrad <k@knt.li> Co-committed-by: konrad <k@knt.li>
This commit is contained in:
parent
e1a7fb4999
commit
5040a76781
16 changed files with 703 additions and 477 deletions
|
@ -8,7 +8,7 @@ describe('User Settings', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Changes the user avatar', () => {
|
it('Changes the user avatar', () => {
|
||||||
cy.visit('/user/settings')
|
cy.visit('/user/settings/avatar')
|
||||||
|
|
||||||
cy.get('input[name=avatarProvider][value=upload]')
|
cy.get('input[name=avatarProvider][value=upload]')
|
||||||
.click()
|
.click()
|
||||||
|
@ -28,9 +28,10 @@ describe('User Settings', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Updates the name', () => {
|
it('Updates the name', () => {
|
||||||
cy.visit('/user/settings')
|
cy.visit('/user/settings/general')
|
||||||
|
|
||||||
cy.get('input#newName')
|
cy.get('.general-settings .control input.input')
|
||||||
|
.first()
|
||||||
.type('Lorem Ipsum')
|
.type('Lorem Ipsum')
|
||||||
cy.get('.card.general-settings .button.is-primary')
|
cy.get('.card.general-settings .button.is-primary')
|
||||||
.contains('Save')
|
.contains('Save')
|
||||||
|
|
|
@ -147,7 +147,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</aside>
|
</aside>
|
||||||
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank" rel="noreferrer noopener nofollow">
|
<a class="menu-bottom-link" :href="poweredByUrl" target="_blank" rel="noreferrer noopener nofollow">
|
||||||
{{ $t('misc.poweredBy') }}
|
{{ $t('misc.poweredBy') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -160,6 +160,7 @@ import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
|
||||||
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
|
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||||
|
import {POWERED_BY} from '@/urls'
|
||||||
|
|
||||||
import logoUrl from '@/assets/logo-full.svg'
|
import logoUrl from '@/assets/logo-full.svg'
|
||||||
|
|
||||||
|
@ -175,6 +176,7 @@ export default {
|
||||||
},
|
},
|
||||||
listUpdating: {},
|
listUpdating: {},
|
||||||
logoUrl,
|
logoUrl,
|
||||||
|
poweredByUrl: POWERED_BY,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
|
|
@ -48,6 +48,14 @@ import CreateSavedFilter from '../views/filters/CreateSavedFilter'
|
||||||
const PasswordResetComponent = () => import('../views/user/PasswordReset')
|
const PasswordResetComponent = () => import('../views/user/PasswordReset')
|
||||||
const GetPasswordResetComponent = () => import('../views/user/RequestPasswordReset')
|
const GetPasswordResetComponent = () => import('../views/user/RequestPasswordReset')
|
||||||
const UserSettingsComponent = () => import('../views/user/Settings')
|
const UserSettingsComponent = () => import('../views/user/Settings')
|
||||||
|
const UserSettingsAvatarComponent = () => import('../views/user/settings/Avatar')
|
||||||
|
const UserSettingsCaldavComponent = () => import('../views/user/settings/Caldav')
|
||||||
|
const UserSettingsDataExportComponent = () => import('../views/user/settings/DataExport')
|
||||||
|
const UserSettingsDeletionComponent = () => import('../views/user/settings/Deletion')
|
||||||
|
const UserSettingsEmailUpdateComponent = () => import('../views/user/settings/EmailUpdate')
|
||||||
|
const UserSettingsGeneralComponent = () => import('../views/user/settings/General')
|
||||||
|
const UserSettingsPasswordUpdateComponent = () => import('../views/user/settings/PasswordUpdate')
|
||||||
|
const UserSettingsTOTPComponent = () => import('../views/user/settings/TOTP')
|
||||||
|
|
||||||
// List Handling
|
// List Handling
|
||||||
const NewListComponent = () => import('../views/list/NewList')
|
const NewListComponent = () => import('../views/list/NewList')
|
||||||
|
@ -115,6 +123,48 @@ const router = createRouter({
|
||||||
path: '/user/settings',
|
path: '/user/settings',
|
||||||
name: 'user.settings',
|
name: 'user.settings',
|
||||||
component: UserSettingsComponent,
|
component: UserSettingsComponent,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/user/settings/avatar',
|
||||||
|
name: 'user.settings.avatar',
|
||||||
|
component: UserSettingsAvatarComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user/settings/caldav',
|
||||||
|
name: 'user.settings.caldav',
|
||||||
|
component: UserSettingsCaldavComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user/settings/data-export',
|
||||||
|
name: 'user.settings.data-export',
|
||||||
|
component: UserSettingsDataExportComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user/settings/deletion',
|
||||||
|
name: 'user.settings.deletion',
|
||||||
|
component: UserSettingsDeletionComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user/settings/email-update',
|
||||||
|
name: 'user.settings.email-update',
|
||||||
|
component: UserSettingsEmailUpdateComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user/settings/general',
|
||||||
|
name: 'user.settings.general',
|
||||||
|
component: UserSettingsGeneralComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user/settings/password-update',
|
||||||
|
name: 'user.settings.password-update',
|
||||||
|
component: UserSettingsPasswordUpdateComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user/settings/totp',
|
||||||
|
name: 'user.settings.totp',
|
||||||
|
component: UserSettingsTOTPComponent,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/user/export/download',
|
path: '/user/export/download',
|
||||||
|
|
|
@ -2,6 +2,7 @@ import {CONFIG} from '../mutation-types'
|
||||||
import {HTTPFactory} from '@/http-common'
|
import {HTTPFactory} from '@/http-common'
|
||||||
import {objectToCamelCase} from '@/helpers/case'
|
import {objectToCamelCase} from '@/helpers/case'
|
||||||
import {redirectToProvider} from '../../helpers/redirectToProvider'
|
import {redirectToProvider} from '../../helpers/redirectToProvider'
|
||||||
|
import {parseURL} from 'ufo'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespaced: true,
|
namespaced: true,
|
||||||
|
@ -35,6 +36,13 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
getters: {
|
||||||
|
migratorsEnabled: state => state.availableMigrators?.length > 0,
|
||||||
|
apiBase() {
|
||||||
|
const {host, protocol} = parseURL(window.API_URL)
|
||||||
|
return protocol + '//' + host
|
||||||
|
},
|
||||||
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
[CONFIG](state, config) {
|
[CONFIG](state, config) {
|
||||||
state.version = config.version
|
state.version = config.version
|
||||||
|
|
|
@ -18,3 +18,9 @@
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-widescreen {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: $widescreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
2
src/urls.js
Normal file
2
src/urls.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export const POWERED_BY = 'https://vikunja.io'
|
||||||
|
export const CALDAV_DOCS = 'https://vikunja.io/docs/caldav/'
|
|
@ -43,6 +43,7 @@
|
||||||
<label class="label" for="totpPasscode">{{ $t('user.auth.totpTitle') }}</label>
|
<label class="label" for="totpPasscode">{{ $t('user.auth.totpTitle') }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input
|
<input
|
||||||
|
autocomplete="one-time-code"
|
||||||
class="input"
|
class="input"
|
||||||
id="totpPasscode"
|
id="totpPasscode"
|
||||||
:placeholder="$t('user.auth.totpPlaceholder')"
|
:placeholder="$t('user.auth.totpPlaceholder')"
|
||||||
|
|
|
@ -1,469 +1,116 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="content-widescreen">
|
||||||
:class="{ 'is-loading': passwordUpdateService.loading || emailUpdateService.loading || totpService.loading }"
|
<div class="user-settings">
|
||||||
class="loader-container is-max-width-desktop">
|
<nav class="navigation">
|
||||||
<!-- General -->
|
<ul>
|
||||||
<card :title="$t('user.settings.general.title')" class="general-settings">
|
<li>
|
||||||
<div class="field">
|
<router-link :to="{name: 'user.settings.general'}">
|
||||||
<label class="label" for="newName">{{ $t('user.settings.general.name') }}</label>
|
{{ $t('user.settings.general.title') }}
|
||||||
<div class="control">
|
</router-link>
|
||||||
<input
|
</li>
|
||||||
@keyup.enter="updateSettings"
|
<li>
|
||||||
class="input"
|
<router-link :to="{name: 'user.settings.password-update'}">
|
||||||
id="newName"
|
{{ $t('user.settings.newPasswordTitle') }}
|
||||||
:placeholder="$t('user.settings.general.newName')"
|
</router-link>
|
||||||
type="text"
|
</li>
|
||||||
v-model="settings.name"/>
|
<li>
|
||||||
|
<router-link :to="{name: 'user.settings.email-update'}">
|
||||||
|
{{ $t('user.settings.updateEmailTitle') }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link :to="{name: 'user.settings.avatar'}">
|
||||||
|
{{ $t('user.settings.avatar.title') }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li v-if="totpEnabled">
|
||||||
|
<router-link :to="{name: 'user.settings.totp'}">
|
||||||
|
{{ $t('user.settings.totp.title') }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link :to="{name: 'user.settings.data-export'}">
|
||||||
|
{{ $t('user.export.title') }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li v-if="migratorsEnabled">
|
||||||
|
<router-link :to="{name: 'migrate.start'}">
|
||||||
|
{{ $t('migrate.title') }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li v-if="caldavEnabled">
|
||||||
|
<router-link :to="{name: 'user.settings.caldav'}">
|
||||||
|
{{ $t('user.settings.caldav.title') }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link :to="{name: 'user.settings.deletion'}">
|
||||||
|
{{ $t('user.deletion.title') }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<section class="view">
|
||||||
|
<router-view/>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label class="label">
|
|
||||||
{{ $t('user.settings.general.defaultList') }}
|
|
||||||
</label>
|
|
||||||
<list-search v-model="defaultList"/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" v-model="settings.emailRemindersEnabled"/>
|
|
||||||
{{ $t('user.settings.general.emailReminders') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" v-model="settings.overdueTasksRemindersEnabled"/>
|
|
||||||
{{ $t('user.settings.general.overdueReminders') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" v-model="settings.discoverableByName"/>
|
|
||||||
{{ $t('user.settings.general.discoverableByName') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" v-model="settings.discoverableByEmail"/>
|
|
||||||
{{ $t('user.settings.general.discoverableByEmail') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" v-model="playSoundWhenDone"/>
|
|
||||||
{{ $t('user.settings.general.playSoundWhenDone') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="is-flex is-align-items-center">
|
|
||||||
<span>
|
|
||||||
{{ $t('user.settings.general.weekStart') }}
|
|
||||||
</span>
|
|
||||||
<div class="select ml-2">
|
|
||||||
<select v-model.number="settings.weekStart">
|
|
||||||
<option value="0">{{ $t('user.settings.general.weekStartSunday') }}</option>
|
|
||||||
<option value="1">{{ $t('user.settings.general.weekStartMonday') }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="is-flex is-align-items-center">
|
|
||||||
<span>
|
|
||||||
{{ $t('user.settings.general.language') }}
|
|
||||||
</span>
|
|
||||||
<div class="select ml-2">
|
|
||||||
<select v-model="language">
|
|
||||||
<option :value="lang.code" v-for="lang in availableLanguages" :key="lang.code">{{ lang.title }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="is-flex is-align-items-center">
|
|
||||||
<span>
|
|
||||||
{{ $t('user.settings.quickAddMagic.title') }}
|
|
||||||
</span>
|
|
||||||
<div class="select ml-2">
|
|
||||||
<select v-model="quickAddMagicMode">
|
|
||||||
<option v-for="set in quickAddMagicPrefixes" :key="set" :value="set">{{ $t(`user.settings.quickAddMagic.${set}`) }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-button
|
|
||||||
:loading="userSettingsService.loading"
|
|
||||||
@click="updateSettings()"
|
|
||||||
class="is-fullwidth mt-4"
|
|
||||||
>
|
|
||||||
{{ $t('misc.save') }}
|
|
||||||
</x-button>
|
|
||||||
</card>
|
|
||||||
|
|
||||||
<!-- Avatar -->
|
|
||||||
<avatar-settings/>
|
|
||||||
|
|
||||||
<!-- Password update -->
|
|
||||||
<card :title="$t('user.settings.newPasswordTitle')">
|
|
||||||
<form @submit.prevent="updatePassword()">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="newPassword">{{ $t('user.settings.newPassword') }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
@keyup.enter="updatePassword"
|
|
||||||
class="input"
|
|
||||||
id="newPassword"
|
|
||||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
|
||||||
type="password"
|
|
||||||
v-model="passwordUpdate.newPassword"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="newPasswordConfirm">{{ $t('user.settings.newPasswordConfirm') }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
@keyup.enter="updatePassword"
|
|
||||||
class="input"
|
|
||||||
id="newPasswordConfirm"
|
|
||||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
|
||||||
type="password"
|
|
||||||
v-model="passwordConfirm"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="currentPassword">{{ $t('user.settings.currentPassword') }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
@keyup.enter="updatePassword"
|
|
||||||
class="input"
|
|
||||||
id="currentPassword"
|
|
||||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
|
||||||
type="password"
|
|
||||||
v-model="passwordUpdate.oldPassword"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<x-button
|
|
||||||
:loading="passwordUpdateService.loading"
|
|
||||||
@click="updatePassword()"
|
|
||||||
class="is-fullwidth mt-4">
|
|
||||||
{{ $t('misc.save') }}
|
|
||||||
</x-button>
|
|
||||||
</card>
|
|
||||||
|
|
||||||
<!-- Update E-Mail -->
|
|
||||||
<card :title="$t('user.settings.updateEmailTitle')">
|
|
||||||
<form @submit.prevent="updateEmail()">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="newEmail">{{ $t('user.settings.updateEmailNew') }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
@keyup.enter="updateEmail"
|
|
||||||
class="input"
|
|
||||||
id="newEmail"
|
|
||||||
:placeholder="$t('user.auth.emailPlaceholder')"
|
|
||||||
type="email"
|
|
||||||
v-model="emailUpdate.newEmail"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="currentPasswordEmail">{{ $t('user.settings.currentPassword') }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
@keyup.enter="updateEmail"
|
|
||||||
class="input"
|
|
||||||
id="currentPasswordEmail"
|
|
||||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
|
||||||
type="password"
|
|
||||||
v-model="emailUpdate.password"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<x-button
|
|
||||||
:loading="emailUpdateService.loading"
|
|
||||||
@click="updateEmail()"
|
|
||||||
class="is-fullwidth mt-4">
|
|
||||||
{{ $t('misc.save') }}
|
|
||||||
</x-button>
|
|
||||||
</card>
|
|
||||||
|
|
||||||
<!-- TOTP -->
|
|
||||||
<card :title="$t('user.settings.totp.title')" v-if="totpEnabled">
|
|
||||||
<x-button
|
|
||||||
:loading="totpService.loading"
|
|
||||||
@click="totpEnroll()"
|
|
||||||
v-if="!totpEnrolled && totp.secret === ''">
|
|
||||||
{{ $t('user.settings.totp.enroll') }}
|
|
||||||
</x-button>
|
|
||||||
<template v-else-if="totp.secret !== '' && !totp.enabled">
|
|
||||||
<p>
|
|
||||||
{{ $t('user.settings.totp.finishSetupPart1') }}
|
|
||||||
<strong>{{ totp.secret }}</strong><br/>
|
|
||||||
{{ $t('user.settings.totp.finishSetupPart2') }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{{ $t('user.settings.totp.scanQR') }}<br/>
|
|
||||||
<img :src="totpQR" alt=""/>
|
|
||||||
</p>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="totpConfirmPasscode">{{ $t('user.settings.totp.passcode') }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
@keyup.enter="totpConfirm()"
|
|
||||||
class="input"
|
|
||||||
id="totpConfirmPasscode"
|
|
||||||
:placeholder="$t('user.settings.totp.passcodePlaceholder')"
|
|
||||||
type="text"
|
|
||||||
v-model="totpConfirmPasscode"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<x-button @click="totpConfirm()">{{ $t('misc.confirm') }}</x-button>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="totp.secret !== '' && totp.enabled">
|
|
||||||
<p>
|
|
||||||
{{ $t('user.settings.totp.setupSuccess') }}
|
|
||||||
</p>
|
|
||||||
<p v-if="!totpDisableForm">
|
|
||||||
<x-button @click="totpDisableForm = true" class="is-danger">{{ $t('misc.disable') }}</x-button>
|
|
||||||
</p>
|
|
||||||
<div v-if="totpDisableForm">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="currentPassword">{{ $t('user.settings.totp.enterPassword') }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
@keyup.enter="totpDisable"
|
|
||||||
class="input"
|
|
||||||
id="currentPassword"
|
|
||||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
|
||||||
type="password"
|
|
||||||
v-focus
|
|
||||||
v-model="totpDisablePassword"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<x-button @click="totpDisable()" class="is-danger">
|
|
||||||
{{ $t('user.settings.totp.disable') }}
|
|
||||||
</x-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</card>
|
|
||||||
|
|
||||||
<!-- Data export -->
|
|
||||||
<data-export/>
|
|
||||||
|
|
||||||
<!-- Migration -->
|
|
||||||
<card :title="$t('migrate.title')" v-if="migratorsEnabled">
|
|
||||||
<x-button
|
|
||||||
:to="{name: 'migrate.start'}"
|
|
||||||
>
|
|
||||||
{{ $t('migrate.import') }}
|
|
||||||
</x-button>
|
|
||||||
</card>
|
|
||||||
|
|
||||||
<!-- Account deletion -->
|
|
||||||
<user-settings-deletion id="deletion"/>
|
|
||||||
|
|
||||||
<!-- Caldav -->
|
|
||||||
<card v-if="caldavEnabled" :title="$t('user.settings.caldav.title')">
|
|
||||||
<p>
|
|
||||||
{{ $t('user.settings.caldav.howTo') }}
|
|
||||||
</p>
|
|
||||||
<div class="field has-addons no-input-mobile">
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<input type="text" v-model="caldavUrl" class="input" readonly/>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<x-button
|
|
||||||
@click="copy(caldavUrl)"
|
|
||||||
:shadow="false"
|
|
||||||
v-tooltip="$t('misc.copy')"
|
|
||||||
icon="paste"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<a href="https://vikunja.io/docs/caldav/" rel="noreferrer noopener nofollow" target="_blank">
|
|
||||||
{{ $t('user.settings.caldav.more') }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</card>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import PasswordUpdateModel from '../../models/passwordUpdate'
|
|
||||||
import PasswordUpdateService from '../../services/passwordUpdateService'
|
|
||||||
import EmailUpdateService from '../../services/emailUpdate'
|
|
||||||
import EmailUpdateModel from '../../models/emailUpdate'
|
|
||||||
import TotpModel from '../../models/totp'
|
|
||||||
import TotpService from '../../services/totp'
|
|
||||||
import UserSettingsService from '../../services/userSettings'
|
|
||||||
import {playSoundWhenDoneKey} from '@/helpers/playPop'
|
|
||||||
import {availableLanguages, saveLanguage, getCurrentLanguage} from '@/i18n'
|
|
||||||
import {getQuickAddMagicMode, setQuickAddMagicMode} from '../../helpers/quickAddMagicMode'
|
|
||||||
import {PrefixMode} from '../../modules/parseTaskText'
|
|
||||||
|
|
||||||
import {mapState} from 'vuex'
|
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'
|
|
||||||
import DataExport from '../../components/user/settings/data-export'
|
|
||||||
|
|
||||||
function getPlaySoundWhenDoneSetting() {
|
|
||||||
return localStorage.getItem(playSoundWhenDoneKey) === 'true' || localStorage.getItem(playSoundWhenDoneKey) === null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
passwordUpdateService: new PasswordUpdateService(),
|
|
||||||
passwordUpdate: new PasswordUpdateModel(),
|
|
||||||
passwordConfirm: '',
|
|
||||||
|
|
||||||
emailUpdateService: new EmailUpdateService(),
|
|
||||||
emailUpdate: new EmailUpdateModel(),
|
|
||||||
|
|
||||||
totpService: new TotpService(),
|
|
||||||
totp: new TotpModel(),
|
|
||||||
totpQR: '',
|
|
||||||
totpEnrolled: false,
|
|
||||||
totpConfirmPasscode: '',
|
|
||||||
totpDisableForm: false,
|
|
||||||
totpDisablePassword: '',
|
|
||||||
playSoundWhenDone: getPlaySoundWhenDoneSetting(),
|
|
||||||
language: getCurrentLanguage(),
|
|
||||||
quickAddMagicMode: getQuickAddMagicMode(),
|
|
||||||
quickAddMagicPrefixes: PrefixMode,
|
|
||||||
|
|
||||||
settings: { ...this.$store.state.auth.settings },
|
|
||||||
userSettingsService: new UserSettingsService(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
UserSettingsDeletion,
|
|
||||||
ListSearch,
|
|
||||||
AvatarSettings,
|
|
||||||
DataExport,
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.totpStatus()
|
|
||||||
},
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.setTitle(this.$t('user.settings.title'))
|
this.setTitle(this.$t('user.settings.title'))
|
||||||
this.anchorHashCheck()
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
defaultList() {
|
...mapState('config', ['totpEnabled', 'caldavEnabled']),
|
||||||
return this.$store.getters['lists/getListById'](this.settings.defaultListId)
|
migratorsEnabled() {
|
||||||
},
|
return this.$store.getters['config/migratorsEnabled']
|
||||||
caldavUrl() {
|
|
||||||
let apiBase = window.API_URL.replace('/api/v1', '')
|
|
||||||
if (apiBase === '') { // Frontend and api on the same host which means we need to prefix the frontend url
|
|
||||||
apiBase = this.$store.state.config.frontendUrl
|
|
||||||
}
|
|
||||||
if (apiBase.endsWith('/')) {
|
|
||||||
apiBase = apiBase.substr(0, apiBase.length - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${apiBase}/dav/principals/${this.userInfo.username}/`
|
|
||||||
},
|
|
||||||
availableLanguages() {
|
|
||||||
return Object.entries(availableLanguages)
|
|
||||||
.map(l => ({code: l[0], title: l[1]}))
|
|
||||||
.sort((a, b) => a.title > b.title)
|
|
||||||
},
|
|
||||||
...mapState({
|
|
||||||
totpEnabled: state => state.config.totpEnabled,
|
|
||||||
migratorsEnabled: state => state.config.availableMigrators !== null && state.config.availableMigrators.length > 0,
|
|
||||||
caldavEnabled: state => state.config.caldavEnabled,
|
|
||||||
userInfo: state => state.auth.info,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
copy,
|
|
||||||
|
|
||||||
async updatePassword() {
|
|
||||||
if (this.passwordConfirm !== this.passwordUpdate.newPassword) {
|
|
||||||
this.$message.error({message: this.$t('user.settings.passwordsDontMatch')})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.passwordUpdateService.update(this.passwordUpdate)
|
|
||||||
this.$message.success({message: this.$t('user.settings.passwordUpdateSuccess')})
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateEmail() {
|
|
||||||
await this.emailUpdateService.update(this.emailUpdate)
|
|
||||||
this.$message.success({message: this.$t('user.settings.updateEmailSuccess')})
|
|
||||||
},
|
|
||||||
|
|
||||||
async totpStatus() {
|
|
||||||
if (!this.totpEnabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.totp = await this.totpService.get()
|
|
||||||
this.totpSetQrCode()
|
|
||||||
} catch(e) {
|
|
||||||
// Error code 1016 means totp is not enabled, we don't need an error in that case.
|
|
||||||
if (e.response && e.response.data && e.response.data.code && e.response.data.code === 1016) {
|
|
||||||
this.totpEnrolled = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async totpSetQrCode() {
|
|
||||||
const qr = await this.totpService.qrcode()
|
|
||||||
const urlCreator = window.URL || window.webkitURL
|
|
||||||
this.totpQR = urlCreator.createObjectURL(qr)
|
|
||||||
},
|
|
||||||
|
|
||||||
async totpEnroll() {
|
|
||||||
this.totp = await this.totpService.enroll()
|
|
||||||
this.totpEnrolled = true
|
|
||||||
this.totpSetQrCode()
|
|
||||||
},
|
|
||||||
|
|
||||||
async totpConfirm() {
|
|
||||||
await this.totpService.enable({passcode: this.totpConfirmPasscode})
|
|
||||||
this.totp.enabled = true
|
|
||||||
this.$message.success({message: this.$t('user.settings.totp.confirmSuccess')})
|
|
||||||
},
|
|
||||||
|
|
||||||
async totpDisable() {
|
|
||||||
await this.totpService.disable({password: this.totpDisablePassword})
|
|
||||||
this.totpEnrolled = false
|
|
||||||
this.totp = new TotpModel()
|
|
||||||
this.$message.success({message: this.$t('user.settings.totp.disableSuccess')})
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateSettings() {
|
|
||||||
localStorage.setItem(playSoundWhenDoneKey, this.playSoundWhenDone)
|
|
||||||
saveLanguage(this.language)
|
|
||||||
setQuickAddMagicMode(this.quickAddMagicMode)
|
|
||||||
this.settings.defaultListId = this.defaultList ? this.defaultList.id : 0
|
|
||||||
|
|
||||||
await this.userSettingsService.update(this.settings)
|
|
||||||
this.$store.commit('auth/setUserSettings', {
|
|
||||||
...this.settings,
|
|
||||||
})
|
|
||||||
this.$message.success({message: this.$t('user.settings.general.savedSuccess')})
|
|
||||||
},
|
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.user-settings {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.navigation {
|
||||||
|
width: 25%;
|
||||||
|
padding-right: 1rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: block;
|
||||||
|
padding: .5rem;
|
||||||
|
color: $dark;
|
||||||
|
width: 100%;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
|
||||||
|
&:hover, &.router-link-active {
|
||||||
|
background: $white;
|
||||||
|
border-color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: $tablet) {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.navigation, .view {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -66,11 +66,11 @@
|
||||||
import {Cropper} from 'vue-advanced-cropper'
|
import {Cropper} from 'vue-advanced-cropper'
|
||||||
import 'vue-advanced-cropper/dist/style.css'
|
import 'vue-advanced-cropper/dist/style.css'
|
||||||
|
|
||||||
import AvatarService from '../../services/avatar'
|
import AvatarService from '@/services/avatar'
|
||||||
import AvatarModel from '../../models/avatar'
|
import AvatarModel from '@/models/avatar'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'avatar-settings',
|
name: 'user-settings-avatar',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
avatarProvider: '',
|
avatarProvider: '',
|
||||||
|
@ -86,6 +86,9 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
Cropper,
|
Cropper,
|
||||||
},
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setTitle(`${this.$t('user.settings.avatar.title')} - ${this.$t('user.settings.title')}`)
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async avatarStatus() {
|
async avatarStatus() {
|
||||||
const { avatarProvider } = await this.avatarService.get({})
|
const { avatarProvider } = await this.avatarService.get({})
|
55
src/views/user/settings/Caldav.vue
Normal file
55
src/views/user/settings/Caldav.vue
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
<template>
|
||||||
|
<card v-if="caldavEnabled" :title="$t('user.settings.caldav.title')">
|
||||||
|
<p>
|
||||||
|
{{ $t('user.settings.caldav.howTo') }}
|
||||||
|
</p>
|
||||||
|
<div class="field has-addons no-input-mobile">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input type="text" v-model="caldavUrl" class="input" readonly/>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<x-button
|
||||||
|
@click="copy(caldavUrl)"
|
||||||
|
:shadow="false"
|
||||||
|
v-tooltip="$t('misc.copy')"
|
||||||
|
icon="paste"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<a href="https://vikunja.io/docs/caldav/" rel="noreferrer noopener nofollow" target="_blank">
|
||||||
|
{{ $t('user.settings.caldav.more') }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import copy from 'copy-to-clipboard'
|
||||||
|
import {mapState} from 'vuex'
|
||||||
|
import {CALDAV_DOCS} from '@/urls'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'user-settings-caldav',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
caldavDocsUrl: CALDAV_DOCS,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setTitle(`${this.$t('user.settings.caldav.title')} - ${this.$t('user.settings.title')}`)
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
caldavUrl() {
|
||||||
|
return `${this.$store.getters['config/apiBase']}/dav/principals/${this.userInfo.username}/`
|
||||||
|
},
|
||||||
|
...mapState('config', ['caldavEnabled']),
|
||||||
|
...mapState({
|
||||||
|
userInfo: state => state.auth.info,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
copy,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -37,10 +37,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import DataExportService from '../../../services/dataExport'
|
import DataExportService from '@/services/dataExport'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'data-export',
|
name: 'user-settings-data-export',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
dataExportService: new DataExportService(),
|
dataExportService: new DataExportService(),
|
||||||
|
@ -48,6 +48,9 @@ export default {
|
||||||
errPasswordRequired: false,
|
errPasswordRequired: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setTitle(`${this.$t('user.export.title')} - ${this.$t('user.settings.title')}`)
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async requestDataExport() {
|
async requestDataExport() {
|
||||||
if (this.password === '') {
|
if (this.password === '') {
|
|
@ -83,9 +83,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import AccountDeleteService from '../../../services/accountDelete'
|
import AccountDeleteService from '@/services/accountDelete'
|
||||||
import {mapState} from 'vuex'
|
import {mapState} from 'vuex'
|
||||||
import {parseDateOrNull} from '../../../helpers/parseDateOrNull'
|
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'user-settings-deletion',
|
name: 'user-settings-deletion',
|
||||||
|
@ -100,6 +100,9 @@ export default {
|
||||||
userDeletionEnabled: state => state.config.userDeletionEnabled,
|
userDeletionEnabled: state => state.config.userDeletionEnabled,
|
||||||
deletionScheduledAt: state => parseDateOrNull(state.auth.info.deletionScheduledAt),
|
deletionScheduledAt: state => parseDateOrNull(state.auth.info.deletionScheduledAt),
|
||||||
}),
|
}),
|
||||||
|
mounted() {
|
||||||
|
this.setTitle(`${this.$t('user.deletion.title')} - ${this.$t('user.settings.title')}`)
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async deleteAccount() {
|
async deleteAccount() {
|
||||||
if (this.password === '') {
|
if (this.password === '') {
|
61
src/views/user/settings/EmailUpdate.vue
Normal file
61
src/views/user/settings/EmailUpdate.vue
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
<template>
|
||||||
|
<card :title="$t('user.settings.updateEmailTitle')">
|
||||||
|
<form @submit.prevent="updateEmail">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="newEmail">{{ $t('user.settings.updateEmailNew') }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
@keyup.enter="updateEmail"
|
||||||
|
class="input"
|
||||||
|
id="newEmail"
|
||||||
|
:placeholder="$t('user.auth.emailPlaceholder')"
|
||||||
|
type="email"
|
||||||
|
v-model="emailUpdate.newEmail"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="currentPasswordEmail">{{ $t('user.settings.currentPassword') }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
@keyup.enter="updateEmail"
|
||||||
|
class="input"
|
||||||
|
id="currentPasswordEmail"
|
||||||
|
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||||
|
type="password"
|
||||||
|
v-model="emailUpdate.password"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<x-button
|
||||||
|
:loading="emailUpdateService.loading"
|
||||||
|
@click="updateEmail"
|
||||||
|
class="is-fullwidth mt-4">
|
||||||
|
{{ $t('misc.save') }}
|
||||||
|
</x-button>
|
||||||
|
</card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import EmailUpdateService from '@/services/emailUpdate'
|
||||||
|
import EmailUpdateModel from '@/models/emailUpdate'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'user-settings-update-email',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
emailUpdateService: new EmailUpdateService(),
|
||||||
|
emailUpdate: new EmailUpdateModel(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setTitle(`${this.$t('user.settings.updateEmailTitle')} - ${this.$t('user.settings.title')}`)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async updateEmail() {
|
||||||
|
await this.emailUpdateService.update(this.emailUpdate)
|
||||||
|
this.$message.success({message: this.$t('user.settings.updateEmailSuccess')})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
169
src/views/user/settings/General.vue
Normal file
169
src/views/user/settings/General.vue
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
<template>
|
||||||
|
<card :title="$t('user.settings.general.title')" class="general-settings" :loading="userSettingsService.loading">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" :for="`newName${id}`">{{ $t('user.settings.general.name') }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
@keyup.enter="updateSettings"
|
||||||
|
class="input"
|
||||||
|
:id="`newName${id}`"
|
||||||
|
:placeholder="$t('user.settings.general.newName')"
|
||||||
|
type="text"
|
||||||
|
v-model="settings.name"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">
|
||||||
|
{{ $t('user.settings.general.defaultList') }}
|
||||||
|
</label>
|
||||||
|
<list-search v-model="defaultList"/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" v-model="settings.emailRemindersEnabled"/>
|
||||||
|
{{ $t('user.settings.general.emailReminders') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" v-model="settings.overdueTasksRemindersEnabled"/>
|
||||||
|
{{ $t('user.settings.general.overdueReminders') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" v-model="settings.discoverableByName"/>
|
||||||
|
{{ $t('user.settings.general.discoverableByName') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" v-model="settings.discoverableByEmail"/>
|
||||||
|
{{ $t('user.settings.general.discoverableByEmail') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" v-model="playSoundWhenDone"/>
|
||||||
|
{{ $t('user.settings.general.playSoundWhenDone') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="is-flex is-align-items-center">
|
||||||
|
<span>
|
||||||
|
{{ $t('user.settings.general.weekStart') }}
|
||||||
|
</span>
|
||||||
|
<div class="select ml-2">
|
||||||
|
<select v-model.number="settings.weekStart">
|
||||||
|
<option value="0">{{ $t('user.settings.general.weekStartSunday') }}</option>
|
||||||
|
<option value="1">{{ $t('user.settings.general.weekStartMonday') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="is-flex is-align-items-center">
|
||||||
|
<span>
|
||||||
|
{{ $t('user.settings.general.language') }}
|
||||||
|
</span>
|
||||||
|
<div class="select ml-2">
|
||||||
|
<select v-model="language">
|
||||||
|
<option :value="lang.code" v-for="lang in availableLanguages" :key="lang.code">{{
|
||||||
|
lang.title
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="is-flex is-align-items-center">
|
||||||
|
<span>
|
||||||
|
{{ $t('user.settings.quickAddMagic.title') }}
|
||||||
|
</span>
|
||||||
|
<div class="select ml-2">
|
||||||
|
<select v-model="quickAddMagicMode">
|
||||||
|
<option v-for="set in quickAddMagicPrefixes" :key="set" :value="set">
|
||||||
|
{{ $t(`user.settings.quickAddMagic.${set}`) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-button
|
||||||
|
:loading="userSettingsService.loading"
|
||||||
|
@click="updateSettings()"
|
||||||
|
class="is-fullwidth mt-4"
|
||||||
|
>
|
||||||
|
{{ $t('misc.save') }}
|
||||||
|
</x-button>
|
||||||
|
</card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {playSoundWhenDoneKey} from '@/helpers/playPop'
|
||||||
|
import {availableLanguages, saveLanguage, getCurrentLanguage} from '@/i18n'
|
||||||
|
import {getQuickAddMagicMode, setQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
|
||||||
|
import UserSettingsService from '@/services/userSettings'
|
||||||
|
import {PrefixMode} from '@/modules/parseTaskText'
|
||||||
|
import ListSearch from '@/components/tasks/partials/listSearch'
|
||||||
|
import {createRandomID} from '@/helpers/randomId'
|
||||||
|
import {playPop} from '@/helpers/playPop'
|
||||||
|
|
||||||
|
function getPlaySoundWhenDoneSetting() {
|
||||||
|
return localStorage.getItem(playSoundWhenDoneKey) === 'true' || localStorage.getItem(playSoundWhenDoneKey) === null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'user-settings-general',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
playSoundWhenDone: getPlaySoundWhenDoneSetting(),
|
||||||
|
language: getCurrentLanguage(),
|
||||||
|
quickAddMagicMode: getQuickAddMagicMode(),
|
||||||
|
quickAddMagicPrefixes: PrefixMode,
|
||||||
|
userSettingsService: new UserSettingsService(),
|
||||||
|
settings: {...this.$store.state.auth.settings},
|
||||||
|
id: createRandomID(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
ListSearch,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
availableLanguages() {
|
||||||
|
return Object.entries(availableLanguages)
|
||||||
|
.map(l => ({code: l[0], title: l[1]}))
|
||||||
|
.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
},
|
||||||
|
defaultList() {
|
||||||
|
return this.$store.getters['lists/getListById'](this.settings.defaultListId)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setTitle(`${this.$t('user.settings.general.title')} - ${this.$t('user.settings.title')}`)
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
playSoundWhenDone(play) {
|
||||||
|
if (play) {
|
||||||
|
playPop()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async updateSettings() {
|
||||||
|
localStorage.setItem(playSoundWhenDoneKey, this.playSoundWhenDone)
|
||||||
|
saveLanguage(this.language)
|
||||||
|
setQuickAddMagicMode(this.quickAddMagicMode)
|
||||||
|
this.settings.defaultListId = this.defaultList ? this.defaultList.id : 0
|
||||||
|
|
||||||
|
await this.userSettingsService.update(this.settings)
|
||||||
|
this.$store.commit('auth/setUserSettings', {
|
||||||
|
...this.settings,
|
||||||
|
})
|
||||||
|
this.$message.success({message: this.$t('user.settings.general.savedSuccess')})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
82
src/views/user/settings/PasswordUpdate.vue
Normal file
82
src/views/user/settings/PasswordUpdate.vue
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
<template>
|
||||||
|
<card :title="$t('user.settings.newPasswordTitle')" :loading="passwordUpdateService.loading">
|
||||||
|
<form @submit.prevent="updatePassword">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="newPassword">{{ $t('user.settings.newPassword') }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
autocomplete="new-password"
|
||||||
|
@keyup.enter="updatePassword"
|
||||||
|
class="input"
|
||||||
|
id="newPassword"
|
||||||
|
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||||
|
type="password"
|
||||||
|
v-model="passwordUpdate.newPassword"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="newPasswordConfirm">{{ $t('user.settings.newPasswordConfirm') }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
autocomplete="new-password"
|
||||||
|
@keyup.enter="updatePassword"
|
||||||
|
class="input"
|
||||||
|
id="newPasswordConfirm"
|
||||||
|
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||||
|
type="password"
|
||||||
|
v-model="passwordConfirm"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="currentPassword">{{ $t('user.settings.currentPassword') }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
autocomplete="current-password"
|
||||||
|
@keyup.enter="updatePassword"
|
||||||
|
class="input"
|
||||||
|
id="currentPassword"
|
||||||
|
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||||
|
type="password"
|
||||||
|
v-model="passwordUpdate.oldPassword"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<x-button
|
||||||
|
:loading="passwordUpdateService.loading"
|
||||||
|
@click="updatePassword"
|
||||||
|
class="is-fullwidth mt-4">
|
||||||
|
{{ $t('misc.save') }}
|
||||||
|
</x-button>
|
||||||
|
</card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import PasswordUpdateService from '@/services/passwordUpdateService'
|
||||||
|
import PasswordUpdateModel from '@/models/passwordUpdate'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'user-settings-password-update',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
passwordUpdateService: new PasswordUpdateService(),
|
||||||
|
passwordUpdate: new PasswordUpdateModel(),
|
||||||
|
passwordConfirm: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setTitle(`${this.$t('user.settings.newPasswordTitle')} - ${this.$t('user.settings.title')}`)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async updatePassword() {
|
||||||
|
if (this.passwordConfirm !== this.passwordUpdate.newPassword) {
|
||||||
|
this.$message.error({message: this.$t('user.settings.passwordsDontMatch')})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.passwordUpdateService.update(this.passwordUpdate)
|
||||||
|
this.$message.success({message: this.$t('user.settings.passwordUpdateSuccess')})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
133
src/views/user/settings/TOTP.vue
Normal file
133
src/views/user/settings/TOTP.vue
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
<template>
|
||||||
|
<card :title="$t('user.settings.totp.title')" v-if="totpEnabled">
|
||||||
|
<x-button
|
||||||
|
:loading="totpService.loading"
|
||||||
|
@click="totpEnroll()"
|
||||||
|
v-if="!totpEnrolled && totp.secret === ''">
|
||||||
|
{{ $t('user.settings.totp.enroll') }}
|
||||||
|
</x-button>
|
||||||
|
<template v-else-if="totp.secret !== '' && !totp.enabled">
|
||||||
|
<p>
|
||||||
|
{{ $t('user.settings.totp.finishSetupPart1') }}
|
||||||
|
<strong>{{ totp.secret }}</strong><br/>
|
||||||
|
{{ $t('user.settings.totp.finishSetupPart2') }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ $t('user.settings.totp.scanQR') }}<br/>
|
||||||
|
<img :src="totpQR" alt=""/>
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="totpConfirmPasscode">{{ $t('user.settings.totp.passcode') }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
@keyup.enter="totpConfirm"
|
||||||
|
class="input"
|
||||||
|
id="totpConfirmPasscode"
|
||||||
|
:placeholder="$t('user.settings.totp.passcodePlaceholder')"
|
||||||
|
type="text"
|
||||||
|
v-model="totpConfirmPasscode"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<x-button @click="totpConfirm">{{ $t('misc.confirm') }}</x-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="totp.secret !== '' && totp.enabled">
|
||||||
|
<p>
|
||||||
|
{{ $t('user.settings.totp.setupSuccess') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!totpDisableForm">
|
||||||
|
<x-button @click="totpDisableForm = true" class="is-danger">{{ $t('misc.disable') }}</x-button>
|
||||||
|
</p>
|
||||||
|
<div v-if="totpDisableForm">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="currentPassword">{{ $t('user.settings.totp.enterPassword') }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
@keyup.enter="totpDisable"
|
||||||
|
class="input"
|
||||||
|
id="currentPassword"
|
||||||
|
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||||
|
type="password"
|
||||||
|
v-focus
|
||||||
|
v-model="totpDisablePassword"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<x-button @click="totpDisable" class="is-danger">
|
||||||
|
{{ $t('user.settings.totp.disable') }}
|
||||||
|
</x-button>
|
||||||
|
<x-button @click="totpDisableForm = false" type="tertary" class="ml-2">
|
||||||
|
{{ $t('misc.cancel') }}
|
||||||
|
</x-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import TotpService from '@/services/totp'
|
||||||
|
import TotpModel from '@/models/totp'
|
||||||
|
import {mapState} from 'vuex'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'user-settings-totp',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
totpService: new TotpService(),
|
||||||
|
totp: new TotpModel(),
|
||||||
|
totpQR: '',
|
||||||
|
totpEnrolled: false,
|
||||||
|
totpConfirmPasscode: '',
|
||||||
|
totpDisableForm: false,
|
||||||
|
totpDisablePassword: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.totpStatus()
|
||||||
|
},
|
||||||
|
computed: mapState({
|
||||||
|
totpEnabled: state => state.config.totpEnabled,
|
||||||
|
}),
|
||||||
|
mounted() {
|
||||||
|
this.setTitle(`${this.$t('user.settings.totp.title')} - ${this.$t('user.settings.title')}`)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async totpStatus() {
|
||||||
|
if (!this.totpEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.totp = await this.totpService.get()
|
||||||
|
this.totpSetQrCode()
|
||||||
|
} catch(e) {
|
||||||
|
// Error code 1016 means totp is not enabled, we don't need an error in that case.
|
||||||
|
if (e.response && e.response.data && e.response.data.code && e.response.data.code === 1016) {
|
||||||
|
this.totpEnrolled = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async totpSetQrCode() {
|
||||||
|
const qr = await this.totpService.qrcode()
|
||||||
|
this.totpQR = window.URL.createObjectURL(qr)
|
||||||
|
},
|
||||||
|
async totpEnroll() {
|
||||||
|
this.totp = await this.totpService.enroll()
|
||||||
|
this.totpEnrolled = true
|
||||||
|
this.totpSetQrCode()
|
||||||
|
},
|
||||||
|
async totpConfirm() {
|
||||||
|
await this.totpService.enable({passcode: this.totpConfirmPasscode})
|
||||||
|
this.totp.enabled = true
|
||||||
|
this.$message.success({message: this.$t('user.settings.totp.confirmSuccess')})
|
||||||
|
},
|
||||||
|
async totpDisable() {
|
||||||
|
await this.totpService.disable({password: this.totpDisablePassword})
|
||||||
|
this.totpEnrolled = false
|
||||||
|
this.totp = new TotpModel()
|
||||||
|
this.$message.success({message: this.$t('user.settings.totp.disableSuccess')})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
Loading…
Reference in a new issue