chore: move frontend files
This commit is contained in:
67
frontend/src/views/user/DataExportDownload.vue
Normal file
67
frontend/src/views/user/DataExportDownload.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<h1>{{ $t('user.export.downloadTitle') }}</h1>
|
||||
<template v-if="isLocalUser">
|
||||
<p>{{ $t('user.export.descriptionPasswordRequired') }}</p>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPasswordDataExport"
|
||||
>
|
||||
{{ $t('user.settings.currentPassword') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPasswordDataExport"
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
class="input"
|
||||
:class="{'is-danger': errPasswordRequired}"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup="() => errPasswordRequired = password === ''"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="errPasswordRequired"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.deletion.passwordRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<x-button
|
||||
v-focus
|
||||
:loading="dataExportService.loading"
|
||||
class="mt-4"
|
||||
@click="download()"
|
||||
>
|
||||
{{ $t('misc.download') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, reactive} from 'vue'
|
||||
import DataExportService from '@/services/dataExport'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const dataExportService = reactive(new DataExportService())
|
||||
const password = ref('')
|
||||
const errPasswordRequired = ref(false)
|
||||
const passwordInput = ref(null)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
|
||||
function download() {
|
||||
if (password.value === '' && isLocalUser.value) {
|
||||
errPasswordRequired.value = true
|
||||
passwordInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
dataExportService.download(password.value)
|
||||
}
|
||||
</script>
|
261
frontend/src/views/user/Login.vue
Normal file
261
frontend/src/views/user/Login.vue
Normal file
@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div>
|
||||
<Message
|
||||
v-if="confirmedEmailSuccess"
|
||||
variant="success"
|
||||
text-align="center"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ $t('user.auth.confirmEmailSuccess') }}
|
||||
</Message>
|
||||
<Message
|
||||
v-if="errorMessage"
|
||||
variant="danger"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</Message>
|
||||
<form
|
||||
v-if="localAuthEnabled"
|
||||
id="loginform"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="username"
|
||||
>{{ $t('user.auth.usernameEmail') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="username"
|
||||
ref="usernameRef"
|
||||
v-focus
|
||||
class="input"
|
||||
name="username"
|
||||
:placeholder="$t('user.auth.usernamePlaceholder')"
|
||||
required
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
tabindex="1"
|
||||
@keyup.enter="submit"
|
||||
@focusout="validateUsernameField()"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!usernameValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.auth.usernameRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="label-with-link">
|
||||
<label
|
||||
class="label"
|
||||
for="password"
|
||||
>{{ $t('user.auth.password') }}</label>
|
||||
<router-link
|
||||
:to="{ name: 'user.password-reset.request' }"
|
||||
class="reset-password-link"
|
||||
tabindex="6"
|
||||
>
|
||||
{{ $t('user.auth.forgotPassword') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<Password
|
||||
v-model="password"
|
||||
tabindex="2"
|
||||
:validate-initially="validatePasswordInitially"
|
||||
@submit="submit"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="needsTotpPasscode"
|
||||
class="field"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
for="totpPasscode"
|
||||
>{{ $t('user.auth.totpTitle') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="totpPasscode"
|
||||
ref="totpPasscode"
|
||||
v-focus
|
||||
autocomplete="one-time-code"
|
||||
class="input"
|
||||
:placeholder="$t('user.auth.totpPlaceholder')"
|
||||
required
|
||||
type="text"
|
||||
tabindex="3"
|
||||
inputmode="numeric"
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<input
|
||||
v-model="rememberMe"
|
||||
type="checkbox"
|
||||
class="mr-1"
|
||||
>
|
||||
{{ $t('user.auth.remember') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
:loading="isLoading"
|
||||
tabindex="4"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
<p
|
||||
v-if="registrationEnabled"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ $t('user.auth.noAccountYet') }}
|
||||
<router-link
|
||||
:to="{ name: 'user.register' }"
|
||||
type="secondary"
|
||||
tabindex="5"
|
||||
>
|
||||
{{ $t('user.auth.createAccount') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="hasOpenIdProviders"
|
||||
class="mt-4"
|
||||
>
|
||||
<x-button
|
||||
v-for="(p, k) in openidConnect.providers"
|
||||
:key="k"
|
||||
variant="secondary"
|
||||
class="is-fullwidth mt-2"
|
||||
@click="redirectToProvider(p)"
|
||||
>
|
||||
{{ $t('user.auth.loginWith', {provider: p.name}) }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeMount, ref} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import Password from '@/components/input/password.vue'
|
||||
|
||||
import {getErrorText} from '@/message'
|
||||
import {redirectToProvider} from '@/helpers/redirectToProvider'
|
||||
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => t('user.auth.login'))
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const {redirectIfSaved} = useRedirectToLastVisited()
|
||||
|
||||
const registrationEnabled = computed(() => configStore.registrationEnabled)
|
||||
const localAuthEnabled = computed(() => configStore.auth.local.enabled)
|
||||
|
||||
const openidConnect = computed(() => configStore.auth.openidConnect)
|
||||
const hasOpenIdProviders = computed(() => openidConnect.value.enabled && openidConnect.value.providers?.length > 0)
|
||||
|
||||
const isLoading = computed(() => authStore.isLoading)
|
||||
|
||||
const confirmedEmailSuccess = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const password = ref('')
|
||||
const validatePasswordInitially = ref(false)
|
||||
const rememberMe = ref(false)
|
||||
|
||||
const authenticated = computed(() => authStore.authenticated)
|
||||
|
||||
onBeforeMount(() => {
|
||||
authStore.verifyEmail().then((confirmed) => {
|
||||
confirmedEmailSuccess.value = confirmed
|
||||
}).catch((e: Error) => {
|
||||
errorMessage.value = e.message
|
||||
})
|
||||
|
||||
// Check if the user is already logged in, if so, redirect them to the homepage
|
||||
if (authenticated.value) {
|
||||
redirectIfSaved()
|
||||
}
|
||||
})
|
||||
|
||||
const usernameValid = ref(true)
|
||||
const usernameRef = ref<HTMLInputElement | null>(null)
|
||||
const validateUsernameField = useDebounceFn(() => {
|
||||
usernameValid.value = usernameRef.value?.value !== ''
|
||||
}, 100)
|
||||
|
||||
const needsTotpPasscode = computed(() => authStore.needsTotpPasscode)
|
||||
const totpPasscode = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function submit() {
|
||||
errorMessage.value = ''
|
||||
// Some browsers prevent Vue bindings from working with autofilled values.
|
||||
// To work around this, we're manually getting the values here instead of relying on vue bindings.
|
||||
// For more info, see https://kolaente.dev/vikunja/frontend/issues/78
|
||||
const credentials = {
|
||||
username: usernameRef.value?.value,
|
||||
password: password.value,
|
||||
longToken: rememberMe.value,
|
||||
}
|
||||
|
||||
if (credentials.username === '' || credentials.password === '') {
|
||||
// Trigger the validation error messages
|
||||
validateUsernameField()
|
||||
validatePasswordInitially.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (needsTotpPasscode.value) {
|
||||
credentials.totpPasscode = totpPasscode.value?.value
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.login(credentials)
|
||||
authStore.setNeedsTotpPasscode(false)
|
||||
} catch (e) {
|
||||
if (e.response?.data.code === 1017 && !credentials.totpPasscode) {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = getErrorText(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.button {
|
||||
margin: 0 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.reset-password-link {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.label-with-link {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: .5rem;
|
||||
|
||||
.label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
93
frontend/src/views/user/OpenIdAuth.vue
Normal file
93
frontend/src/views/user/OpenIdAuth.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div>
|
||||
<Message
|
||||
v-if="errorMessage"
|
||||
variant="danger"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</Message>
|
||||
<Message
|
||||
v-if="errorMessageFromQuery"
|
||||
variant="danger"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ errorMessageFromQuery }}
|
||||
</Message>
|
||||
<Message v-if="loading">
|
||||
{{ $t('user.auth.authenticating') }}
|
||||
</Message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'Auth' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {getErrorText} from '@/message'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const route = useRoute()
|
||||
const {redirectIfSaved} = useRedirectToLastVisited()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = computed(() => authStore.isLoading)
|
||||
const errorMessage = ref('')
|
||||
const errorMessageFromQuery = computed(() => route.query.error)
|
||||
|
||||
async function authenticateWithCode() {
|
||||
// This component gets mounted twice: The first time when the actual auth request hits the frontend,
|
||||
// the second time after that auth request succeeded and the outer component "content-no-auth" isn't used
|
||||
// but instead the "content-auth" component is used. Because this component is just a route and thus
|
||||
// gets mounted as part of a <router-view/> which both the content-auth and content-no-auth components have,
|
||||
// this re-mounts the component, even if the user is already authenticated.
|
||||
// To make sure we only try to authenticate the user once, we set this "authenticating" lock in localStorage
|
||||
// which ensures only one auth request is done at a time. We don't simply check if the user is already
|
||||
// authenticated to not prevent the whole authentication if some user is already logged in.
|
||||
if (localStorage.getItem('authenticating')) {
|
||||
return
|
||||
}
|
||||
localStorage.setItem('authenticating', 'true')
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
if (typeof route.query.error !== 'undefined') {
|
||||
localStorage.removeItem('authenticating')
|
||||
errorMessage.value = typeof route.query.message !== 'undefined'
|
||||
? route.query.message as string
|
||||
: t('user.auth.openIdGeneralError')
|
||||
return
|
||||
}
|
||||
|
||||
const state = localStorage.getItem('state')
|
||||
if (typeof route.query.state === 'undefined' || route.query.state !== state) {
|
||||
localStorage.removeItem('authenticating')
|
||||
errorMessage.value = t('user.auth.openIdStateError')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.openIdAuth({
|
||||
provider: route.params.provider,
|
||||
code: route.query.code,
|
||||
})
|
||||
redirectIfSaved()
|
||||
} catch(e) {
|
||||
errorMessage.value = getErrorText(e)
|
||||
} finally {
|
||||
localStorage.removeItem('authenticating')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => authenticateWithCode())
|
||||
</script>
|
91
frontend/src/views/user/PasswordReset.vue
Normal file
91
frontend/src/views/user/PasswordReset.vue
Normal file
@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div>
|
||||
<Message
|
||||
v-if="errorMsg"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ errorMsg }}
|
||||
</Message>
|
||||
<div
|
||||
v-if="successMessage"
|
||||
class="has-text-centered mb-4"
|
||||
>
|
||||
<Message variant="success">
|
||||
{{ successMessage }}
|
||||
</Message>
|
||||
<x-button
|
||||
:to="{ name: 'user.login' }"
|
||||
class="mt-4"
|
||||
>
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<form
|
||||
v-if="!successMessage"
|
||||
id="form"
|
||||
@submit.prevent="resetPassword"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="password"
|
||||
>{{ $t('user.auth.password') }}</label>
|
||||
<Password
|
||||
@submit="resetPassword"
|
||||
@update:modelValue="v => credentials.password = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<x-button
|
||||
:loading="passwordResetService.loading"
|
||||
@click="resetPassword"
|
||||
>
|
||||
{{ $t('user.auth.resetPassword') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive} from 'vue'
|
||||
|
||||
import PasswordResetModel from '@/models/passwordReset'
|
||||
import PasswordResetService from '@/services/passwordReset'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import Password from '@/components/input/password.vue'
|
||||
|
||||
const credentials = reactive({
|
||||
password: '',
|
||||
})
|
||||
|
||||
const passwordResetService = reactive(new PasswordResetService())
|
||||
const errorMsg = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
async function resetPassword() {
|
||||
errorMsg.value = ''
|
||||
|
||||
if(credentials.password === '') {
|
||||
return
|
||||
}
|
||||
|
||||
const passwordReset = new PasswordResetModel({newPassword: credentials.password})
|
||||
try {
|
||||
const {message} = await passwordResetService.resetPassword(passwordReset)
|
||||
successMessage.value = message
|
||||
localStorage.removeItem('passwordResetToken')
|
||||
} catch (e) {
|
||||
errorMsg.value = e.response.data.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button {
|
||||
margin: 0 0.4rem 0 0;
|
||||
}
|
||||
</style>
|
176
frontend/src/views/user/Register.vue
Normal file
176
frontend/src/views/user/Register.vue
Normal file
@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div>
|
||||
<Message
|
||||
v-if="errorMessage !== ''"
|
||||
variant="danger"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</Message>
|
||||
<form
|
||||
id="registerform"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="username"
|
||||
>{{ $t('user.auth.username') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="username"
|
||||
v-model="credentials.username"
|
||||
v-focus
|
||||
class="input"
|
||||
name="username"
|
||||
:placeholder="$t('user.auth.usernamePlaceholder')"
|
||||
required
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
@keyup.enter="submit"
|
||||
@focusout="validateUsername"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!usernameValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.auth.usernameRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="email"
|
||||
>{{ $t('user.auth.email') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="email"
|
||||
v-model="credentials.email"
|
||||
class="input"
|
||||
name="email"
|
||||
:placeholder="$t('user.auth.emailPlaceholder')"
|
||||
required
|
||||
type="email"
|
||||
@keyup.enter="submit"
|
||||
@focusout="validateEmail"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!emailValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.auth.emailInvalid') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="password"
|
||||
>{{ $t('user.auth.password') }}</label>
|
||||
<Password
|
||||
:validate-initially="validatePasswordInitially"
|
||||
@submit="submit"
|
||||
@update:modelValue="v => credentials.password = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
id="register-submit"
|
||||
:loading="isLoading"
|
||||
class="mr-2"
|
||||
:disabled="!everythingValid"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('user.auth.createAccount') }}
|
||||
</x-button>
|
||||
|
||||
<Message
|
||||
v-if="configStore.demoModeEnabled"
|
||||
variant="warning"
|
||||
class="mt-4"
|
||||
>
|
||||
{{ $t('demo.title') }}
|
||||
{{ $t('demo.accountWillBeDeleted') }}<br>
|
||||
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
|
||||
</Message>
|
||||
|
||||
<p class="mt-2">
|
||||
{{ $t('user.auth.alreadyHaveAnAccount') }}
|
||||
<router-link :to="{ name: 'user.login' }">
|
||||
{{ $t('user.auth.login') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
import {ref, reactive, toRaw, computed, onBeforeMount} from 'vue'
|
||||
|
||||
import router from '@/router'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import {isEmail} from '@/helpers/isEmail'
|
||||
import Password from '@/components/input/password.vue'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// FIXME: use the `beforeEnter` hook of vue-router
|
||||
// Check if the user is already logged in, if so, redirect them to the homepage
|
||||
onBeforeMount(() => {
|
||||
if (authStore.authenticated) {
|
||||
router.push({name: 'home'})
|
||||
}
|
||||
})
|
||||
|
||||
const credentials = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const isLoading = computed(() => authStore.isLoading)
|
||||
const errorMessage = ref('')
|
||||
const validatePasswordInitially = ref(false)
|
||||
|
||||
const DEBOUNCE_TIME = 100
|
||||
|
||||
// debouncing to prevent error messages when clicking on the log in button
|
||||
const emailValid = ref(true)
|
||||
const validateEmail = useDebounceFn(() => {
|
||||
emailValid.value = isEmail(credentials.email)
|
||||
}, DEBOUNCE_TIME)
|
||||
|
||||
const usernameValid = ref(true)
|
||||
const validateUsername = useDebounceFn(() => {
|
||||
usernameValid.value = credentials.username !== ''
|
||||
}, DEBOUNCE_TIME)
|
||||
|
||||
const everythingValid = computed(() => {
|
||||
return credentials.username !== '' &&
|
||||
credentials.email !== '' &&
|
||||
credentials.password !== '' &&
|
||||
emailValid.value &&
|
||||
usernameValid.value
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
errorMessage.value = ''
|
||||
validatePasswordInitially.value = true
|
||||
|
||||
if (!everythingValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.register(toRaw(credentials))
|
||||
} catch (e) {
|
||||
errorMessage.value = e?.message
|
||||
}
|
||||
}
|
||||
</script>
|
94
frontend/src/views/user/RequestPasswordReset.vue
Normal file
94
frontend/src/views/user/RequestPasswordReset.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div>
|
||||
<Message
|
||||
v-if="errorMsg"
|
||||
variant="danger"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ errorMsg }}
|
||||
</Message>
|
||||
<div
|
||||
v-if="isSuccess"
|
||||
class="has-text-centered mb-4"
|
||||
>
|
||||
<Message variant="success">
|
||||
{{ $t('user.auth.resetPasswordSuccess') }}
|
||||
</Message>
|
||||
<x-button
|
||||
:to="{ name: 'user.login' }"
|
||||
class="mt-4"
|
||||
>
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<form
|
||||
v-if="!isSuccess"
|
||||
@submit.prevent="requestPasswordReset"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="email"
|
||||
>{{ $t('user.auth.email') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="email"
|
||||
v-model="passwordReset.email"
|
||||
v-focus
|
||||
class="input"
|
||||
name="email"
|
||||
:placeholder="$t('user.auth.emailPlaceholder')"
|
||||
required
|
||||
type="email"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<x-button
|
||||
type="submit"
|
||||
:loading="passwordResetService.loading"
|
||||
>
|
||||
{{ $t('user.auth.resetPasswordAction') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
:to="{ name: 'user.login' }"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive} from 'vue'
|
||||
|
||||
import PasswordResetModel from '@/models/passwordReset'
|
||||
import PasswordResetService from '@/services/passwordReset'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
|
||||
const passwordResetService = shallowReactive(new PasswordResetService())
|
||||
const passwordReset = ref(new PasswordResetModel())
|
||||
const errorMsg = ref('')
|
||||
const isSuccess = ref(false)
|
||||
|
||||
async function requestPasswordReset() {
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
await passwordResetService.requestResetPassword(passwordReset.value)
|
||||
isSuccess.value = true
|
||||
} catch (e) {
|
||||
errorMsg.value = e.response.data.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button {
|
||||
margin: 0 0.4rem 0 0;
|
||||
}
|
||||
</style>
|
141
frontend/src/views/user/Settings.vue
Normal file
141
frontend/src/views/user/Settings.vue
Normal file
@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="content-widescreen">
|
||||
<div class="user-settings">
|
||||
<nav class="navigation">
|
||||
<ul>
|
||||
<li
|
||||
v-for="({routeName, title }, index) in navigationItems"
|
||||
:key="index"
|
||||
>
|
||||
<router-link
|
||||
class="navigation-link"
|
||||
:to="{name: routeName}"
|
||||
>
|
||||
{{ title }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<section class="view">
|
||||
<router-view />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTitle } from '@/composables/useTitle'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const { t } = useI18n({useScope: 'global'})
|
||||
useTitle(() => t('user.settings.title'))
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const totpEnabled = computed(() => configStore.totpEnabled)
|
||||
const caldavEnabled = computed(() => configStore.caldavEnabled)
|
||||
const migratorsEnabled = computed(() => configStore.migratorsEnabled)
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
const userDeletionEnabled = computed(() => configStore.userDeletionEnabled)
|
||||
|
||||
const navigationItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
title: t('user.settings.general.title'),
|
||||
routeName: 'user.settings.general',
|
||||
},
|
||||
{
|
||||
title: t('user.settings.newPasswordTitle'),
|
||||
routeName: 'user.settings.password-update',
|
||||
condition: isLocalUser.value,
|
||||
},
|
||||
{
|
||||
title: t('user.settings.updateEmailTitle'),
|
||||
routeName: 'user.settings.email-update',
|
||||
condition: isLocalUser.value,
|
||||
},
|
||||
{
|
||||
title: t('user.settings.avatar.title'),
|
||||
routeName: 'user.settings.avatar',
|
||||
},
|
||||
{
|
||||
title: t('user.settings.totp.title'),
|
||||
routeName: 'user.settings.totp',
|
||||
condition: totpEnabled.value,
|
||||
},
|
||||
{
|
||||
title: t('user.export.title'),
|
||||
routeName: 'user.settings.data-export',
|
||||
},
|
||||
{
|
||||
title: t('migrate.title'),
|
||||
routeName: 'migrate.start',
|
||||
condition: migratorsEnabled.value,
|
||||
},
|
||||
{
|
||||
title: t('user.settings.caldav.title'),
|
||||
routeName: 'user.settings.caldav',
|
||||
condition: caldavEnabled.value,
|
||||
},
|
||||
{
|
||||
title: t('user.settings.apiTokens.title'),
|
||||
routeName: 'user.settings.apiTokens',
|
||||
},
|
||||
{
|
||||
title: t('user.deletion.title'),
|
||||
routeName: 'user.settings.deletion',
|
||||
condition: userDeletionEnabled.value,
|
||||
},
|
||||
]
|
||||
|
||||
return items.filter(({condition}) => condition !== false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-settings {
|
||||
display: flex;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 25%;
|
||||
padding-right: 1rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
width: 100%;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-link {
|
||||
display: block;
|
||||
padding: .5rem;
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
border-left: 3px solid transparent;
|
||||
|
||||
&:hover,
|
||||
&.router-link-active {
|
||||
background: var(--white);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.view {
|
||||
width: 75%;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
width: 100%;
|
||||
padding-left: 0;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
353
frontend/src/views/user/settings/ApiTokens.vue
Normal file
353
frontend/src/views/user/settings/ApiTokens.vue
Normal file
@ -0,0 +1,353 @@
|
||||
<script setup lang="ts">
|
||||
import ApiTokenService from '@/services/apiToken'
|
||||
import {computed, onMounted, ref} from 'vue'
|
||||
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
|
||||
import XButton from '@/components/input/button.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ApiTokenModel from '@/models/apiTokenModel'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import {MILLISECONDS_A_DAY} from '@/constants/date'
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import type {IApiToken} from '@/modelTypes/IApiToken'
|
||||
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
||||
|
||||
const service = new ApiTokenService()
|
||||
const tokens = ref<IApiToken[]>([])
|
||||
const apiDocsUrl = window.API_URL + '/docs'
|
||||
const showCreateForm = ref(false)
|
||||
const availableRoutes = ref(null)
|
||||
const newToken = ref<IApiToken>(new ApiTokenModel())
|
||||
const newTokenExpiry = ref<string | number>(30)
|
||||
const newTokenExpiryCustom = ref(new Date())
|
||||
const newTokenPermissions = ref({})
|
||||
const newTokenPermissionsGroup = ref({})
|
||||
const newTokenTitleValid = ref(true)
|
||||
const apiTokenTitle = ref()
|
||||
const tokenCreatedSuccessMessage = ref('')
|
||||
|
||||
const showDeleteModal = ref<boolean>(false)
|
||||
const tokenToDelete = ref<IApiToken>()
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
locale: getFlatpickrLanguage(),
|
||||
minDate: now,
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
tokens.value = await service.getAll()
|
||||
availableRoutes.value = await service.getAvailableRoutes()
|
||||
resetPermissions()
|
||||
})
|
||||
|
||||
function resetPermissions() {
|
||||
newTokenPermissions.value = {}
|
||||
Object.entries(availableRoutes.value).forEach(entry => {
|
||||
const [group, routes] = entry
|
||||
newTokenPermissions.value[group] = {}
|
||||
Object.keys(routes).forEach(r => {
|
||||
newTokenPermissions.value[group][r] = false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteToken() {
|
||||
await service.delete(tokenToDelete.value)
|
||||
showDeleteModal.value = false
|
||||
const index = tokens.value.findIndex(el => el.id === tokenToDelete.value.id)
|
||||
tokenToDelete.value = null
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
tokens.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
if (!newTokenTitleValid.value) {
|
||||
apiTokenTitle.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const expiry = Number(newTokenExpiry.value)
|
||||
if (!isNaN(expiry)) {
|
||||
// if it's a number, we assume it's the number of days in the future
|
||||
newToken.value.expiresAt = new Date((+new Date()) + expiry * MILLISECONDS_A_DAY)
|
||||
} else {
|
||||
newToken.value.expiresAt = new Date(newTokenExpiryCustom.value)
|
||||
}
|
||||
|
||||
newToken.value.permissions = {}
|
||||
Object.entries(newTokenPermissions.value).forEach(([key, ps]) => {
|
||||
const all = Object.entries(ps)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.filter(([_, v]) => v)
|
||||
.map(p => p[0])
|
||||
if (all.length > 0) {
|
||||
newToken.value.permissions[key] = all
|
||||
}
|
||||
})
|
||||
|
||||
const token = await service.create(newToken.value)
|
||||
tokenCreatedSuccessMessage.value = t('user.settings.apiTokens.tokenCreatedSuccess', {token: token.token})
|
||||
newToken.value = new ApiTokenModel()
|
||||
newTokenExpiry.value = 30
|
||||
newTokenExpiryCustom.value = new Date()
|
||||
resetPermissions()
|
||||
tokens.value.push(token)
|
||||
showCreateForm.value = false
|
||||
}
|
||||
|
||||
function formatPermissionTitle(title: string): string {
|
||||
return title.replaceAll('_', ' ')
|
||||
}
|
||||
|
||||
function selectPermissionGroup(group: string, checked: boolean) {
|
||||
Object.entries(availableRoutes.value[group]).forEach(entry => {
|
||||
const [key] = entry
|
||||
newTokenPermissions.value[group][key] = checked
|
||||
})
|
||||
}
|
||||
|
||||
function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
|
||||
if (checked) {
|
||||
// Check if all permissions of that group are checked and check the "select all" checkbox in that case
|
||||
let allChecked = true
|
||||
Object.entries(availableRoutes.value[group]).forEach(entry => {
|
||||
const [key] = entry
|
||||
if (!newTokenPermissions.value[group][key]) {
|
||||
allChecked = false
|
||||
}
|
||||
})
|
||||
|
||||
if (allChecked) {
|
||||
newTokenPermissionsGroup.value[group] = true
|
||||
}
|
||||
} else {
|
||||
newTokenPermissionsGroup.value[group] = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<card :title="$t('user.settings.apiTokens.title')">
|
||||
<Message
|
||||
v-if="tokenCreatedSuccessMessage !== ''"
|
||||
class="has-text-centered mb-4"
|
||||
>
|
||||
{{ tokenCreatedSuccessMessage }}<br>
|
||||
{{ $t('user.settings.apiTokens.tokenCreatedNotSeeAgain') }}
|
||||
</Message>
|
||||
|
||||
<p>
|
||||
{{ $t('user.settings.apiTokens.general') }}
|
||||
<BaseButton :href="apiDocsUrl">
|
||||
{{ $t('user.settings.apiTokens.apiDocs') }}
|
||||
</BaseButton>
|
||||
.
|
||||
</p>
|
||||
|
||||
<table
|
||||
v-if="tokens.length > 0"
|
||||
class="table"
|
||||
>
|
||||
<tr>
|
||||
<th>{{ $t('misc.id') }}</th>
|
||||
<th>{{ $t('user.settings.apiTokens.attributes.title') }}</th>
|
||||
<th>{{ $t('user.settings.apiTokens.attributes.permissions') }}</th>
|
||||
<th>{{ $t('user.settings.apiTokens.attributes.expiresAt') }}</th>
|
||||
<th>{{ $t('misc.created') }}</th>
|
||||
<th class="has-text-right">
|
||||
{{ $t('misc.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="tk in tokens"
|
||||
:key="tk.id"
|
||||
>
|
||||
<td>{{ tk.id }}</td>
|
||||
<td>{{ tk.title }}</td>
|
||||
<td class="is-capitalized">
|
||||
<template
|
||||
v-for="(v, p) in tk.permissions"
|
||||
:key="'permission-' + p"
|
||||
>
|
||||
<strong>{{ formatPermissionTitle(p) }}:</strong>
|
||||
{{ v.map(formatPermissionTitle).join(', ') }}
|
||||
<br>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
{{ formatDateShort(tk.expiresAt) }}
|
||||
<p
|
||||
v-if="tk.expiresAt < new Date()"
|
||||
class="has-text-danger"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.expired', {ago: formatDateSince(tk.expiresAt)}) }}
|
||||
</p>
|
||||
</td>
|
||||
<td>{{ formatDateShort(tk.created) }}</td>
|
||||
<td class="has-text-right">
|
||||
<XButton
|
||||
variant="secondary"
|
||||
@click="() => {tokenToDelete = tk; showDeleteModal = true}"
|
||||
>
|
||||
{{ $t('misc.delete') }}
|
||||
</XButton>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<form
|
||||
v-if="showCreateForm"
|
||||
@submit.prevent="createToken"
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="apiTokenTitle"
|
||||
>{{ $t('user.settings.apiTokens.attributes.title') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="apiTokenTitle"
|
||||
ref="apiTokenTitle"
|
||||
v-model="newToken.title"
|
||||
v-focus
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('user.settings.apiTokens.attributes.titlePlaceholder')"
|
||||
@keyup="() => newTokenTitleValid = newToken.title !== ''"
|
||||
@focusout="() => newTokenTitleValid = newToken.title !== ''"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!newTokenTitleValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.titleRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Expiry -->
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="apiTokenExpiry"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.attributes.expiresAt') }}
|
||||
</label>
|
||||
<div class="is-flex">
|
||||
<div class="control select">
|
||||
<select
|
||||
id="apiTokenExpiry"
|
||||
v-model="newTokenExpiry"
|
||||
class="select"
|
||||
>
|
||||
<option value="30">
|
||||
{{ $t('user.settings.apiTokens.30d') }}
|
||||
</option>
|
||||
<option value="60">
|
||||
{{ $t('user.settings.apiTokens.60d') }}
|
||||
</option>
|
||||
<option value="90">
|
||||
{{ $t('user.settings.apiTokens.90d') }}
|
||||
</option>
|
||||
<option value="custom">
|
||||
{{ $t('misc.custom') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<flat-pickr
|
||||
v-if="newTokenExpiry === 'custom'"
|
||||
v-model="newTokenExpiryCustom"
|
||||
class="ml-2"
|
||||
:config="flatPickerConfig"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permissions -->
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>
|
||||
<p>{{ $t('user.settings.apiTokens.permissionExplanation') }}</p>
|
||||
<div
|
||||
v-for="(routes, group) in availableRoutes"
|
||||
:key="group"
|
||||
class="mb-2"
|
||||
>
|
||||
<strong class="is-capitalized">{{ formatPermissionTitle(group) }}</strong><br>
|
||||
<template
|
||||
v-if="Object.keys(routes).length > 1"
|
||||
>
|
||||
<Fancycheckbox
|
||||
v-model="newTokenPermissionsGroup[group]"
|
||||
class="mr-2 is-italic"
|
||||
@update:modelValue="checked => selectPermissionGroup(group, checked)"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.selectAll') }}
|
||||
</Fancycheckbox>
|
||||
<br>
|
||||
</template>
|
||||
<template
|
||||
v-for="(paths, route) in routes"
|
||||
:key="group+'-'+route"
|
||||
>
|
||||
<Fancycheckbox
|
||||
v-model="newTokenPermissions[group][route]"
|
||||
class="mr-2 is-capitalized"
|
||||
@update:modelValue="checked => toggleGroupPermissionsFromChild(group, checked)"
|
||||
>
|
||||
{{ formatPermissionTitle(route) }}
|
||||
</Fancycheckbox>
|
||||
<br>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<XButton
|
||||
:loading="service.loading"
|
||||
@click="createToken"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.createToken') }}
|
||||
</XButton>
|
||||
</form>
|
||||
|
||||
<XButton
|
||||
v-else
|
||||
icon="plus"
|
||||
class="mb-4"
|
||||
:loading="service.loading"
|
||||
@click="() => showCreateForm = true"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.createAToken') }}
|
||||
</XButton>
|
||||
|
||||
<modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteToken()"
|
||||
>
|
||||
<template #header>
|
||||
{{ $t('user.settings.apiTokens.delete.header') }}
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('user.settings.apiTokens.delete.text1', {token: tokenToDelete.title}) }}<br>
|
||||
{{ $t('user.settings.apiTokens.delete.text2') }}
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
</card>
|
||||
</template>
|
168
frontend/src/views/user/settings/Avatar.vue
Normal file
168
frontend/src/views/user/settings/Avatar.vue
Normal file
@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<card :title="$t('user.settings.avatar.title')">
|
||||
<div class="control mb-4">
|
||||
<label
|
||||
v-for="(label, providerId) in AVATAR_PROVIDERS"
|
||||
:key="providerId"
|
||||
class="radio"
|
||||
>
|
||||
<input
|
||||
v-model="avatarProvider"
|
||||
name="avatarProvider"
|
||||
type="radio"
|
||||
:value="providerId"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="avatarProvider === 'upload'">
|
||||
<input
|
||||
ref="avatarUploadInput"
|
||||
accept="image/*"
|
||||
class="is-hidden"
|
||||
type="file"
|
||||
@change="cropAvatar"
|
||||
>
|
||||
|
||||
<x-button
|
||||
v-if="!isCropAvatar"
|
||||
:loading="avatarService.loading || loading"
|
||||
@click="avatarUploadInput.click()"
|
||||
>
|
||||
{{ $t('user.settings.avatar.uploadAvatar') }}
|
||||
</x-button>
|
||||
<template v-else>
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
:src="avatarToCrop"
|
||||
:stencil-props="{aspectRatio: 1}"
|
||||
class="mb-4 cropper"
|
||||
@ready="() => loading = false"
|
||||
/>
|
||||
<x-button
|
||||
v-cy="'uploadAvatar'"
|
||||
:loading="avatarService.loading || loading"
|
||||
@click="uploadAvatar"
|
||||
>
|
||||
{{ $t('user.settings.avatar.uploadAvatar') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="mt-2"
|
||||
>
|
||||
<x-button
|
||||
:loading="avatarService.loading || loading"
|
||||
class="is-fullwidth"
|
||||
@click="updateAvatarStatus()"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'UserSettingsAvatar' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {Cropper} from 'vue-advanced-cropper'
|
||||
import 'vue-advanced-cropper/dist/style.css'
|
||||
|
||||
import AvatarService from '@/services/avatar'
|
||||
import AvatarModel from '@/models/avatar'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const AVATAR_PROVIDERS = computed(() => ({
|
||||
default: t('misc.default'),
|
||||
initials: t('user.settings.avatar.initials'),
|
||||
gravatar: t('user.settings.avatar.gravatar'),
|
||||
marble: t('user.settings.avatar.marble'),
|
||||
upload: t('user.settings.avatar.upload'),
|
||||
}))
|
||||
|
||||
useTitle(() => `${t('user.settings.avatar.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
const avatarService = shallowReactive(new AvatarService())
|
||||
// Seperate variable because some things we're doing in browser take a bit
|
||||
const loading = ref(false)
|
||||
|
||||
|
||||
const avatarProvider = ref('')
|
||||
async function avatarStatus() {
|
||||
const { avatarProvider: currentProvider } = await avatarService.get({})
|
||||
avatarProvider.value = currentProvider
|
||||
}
|
||||
avatarStatus()
|
||||
|
||||
|
||||
async function updateAvatarStatus() {
|
||||
await avatarService.update(new AvatarModel({avatarProvider: avatarProvider.value}))
|
||||
success({message: t('user.settings.avatar.statusUpdateSuccess')})
|
||||
authStore.reloadAvatar()
|
||||
}
|
||||
|
||||
const cropper = ref()
|
||||
const isCropAvatar = ref(false)
|
||||
|
||||
async function uploadAvatar() {
|
||||
loading.value = true
|
||||
const {canvas} = cropper.value.getResult()
|
||||
|
||||
if (!canvas) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await new Promise(resolve => canvas.toBlob(blob => resolve(blob)))
|
||||
await avatarService.create(blob)
|
||||
success({message: t('user.settings.avatar.setSuccess')})
|
||||
authStore.reloadAvatar()
|
||||
} finally {
|
||||
loading.value = false
|
||||
isCropAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const avatarToCrop = ref()
|
||||
const avatarUploadInput = ref()
|
||||
function cropAvatar() {
|
||||
const avatar = avatarUploadInput.value.files
|
||||
|
||||
if (avatar.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
const reader = new FileReader()
|
||||
reader.onload = e => {
|
||||
avatarToCrop.value = e.target.result
|
||||
isCropAvatar.value = true
|
||||
}
|
||||
reader.onloadend = () => loading.value = false
|
||||
reader.readAsDataURL(avatar[0])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.cropper {
|
||||
height: 80vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.vue-advanced-cropper__background {
|
||||
background: var(--white);
|
||||
}
|
||||
</style>
|
147
frontend/src/views/user/settings/Caldav.vue
Normal file
147
frontend/src/views/user/settings/Caldav.vue
Normal file
@ -0,0 +1,147 @@
|
||||
<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
|
||||
v-model="caldavUrl"
|
||||
type="text"
|
||||
class="input"
|
||||
readonly
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
v-tooltip="$t('misc.copy')"
|
||||
:shadow="false"
|
||||
icon="paste"
|
||||
@click="copy(caldavUrl)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-5 mb-4 has-text-weight-bold">
|
||||
{{ $t('user.settings.caldav.tokens') }}
|
||||
</h5>
|
||||
|
||||
<p>
|
||||
{{ isLocalUser ? $t('user.settings.caldav.tokensHowTo') : $t('user.settings.caldav.mustUseToken') }}
|
||||
<template v-if="!isLocalUser">
|
||||
<br>
|
||||
<i18n-t
|
||||
keypath="user.settings.caldav.usernameIs"
|
||||
scope="global"
|
||||
>
|
||||
<strong>{{ username }}</strong>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<table
|
||||
v-if="tokens.length > 0"
|
||||
class="table"
|
||||
>
|
||||
<tr>
|
||||
<th>{{ $t('misc.id') }}</th>
|
||||
<th>{{ $t('misc.created') }}</th>
|
||||
<th class="has-text-right">
|
||||
{{ $t('misc.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="tk in tokens"
|
||||
:key="tk.id"
|
||||
>
|
||||
<td>{{ tk.id }}</td>
|
||||
<td>{{ formatDateShort(tk.created) }}</td>
|
||||
<td class="has-text-right">
|
||||
<x-button
|
||||
variant="secondary"
|
||||
@click="deleteToken(tk)"
|
||||
>
|
||||
{{ $t('misc.delete') }}
|
||||
</x-button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<Message
|
||||
v-if="newToken"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ $t('user.settings.caldav.tokenCreated', {token: newToken.token}) }}<br>
|
||||
{{ $t('user.settings.caldav.wontSeeItAgain') }}
|
||||
</Message>
|
||||
|
||||
<x-button
|
||||
icon="plus"
|
||||
class="mb-4"
|
||||
:loading="service.loading"
|
||||
@click="createToken"
|
||||
>
|
||||
{{ $t('user.settings.caldav.createToken') }}
|
||||
</x-button>
|
||||
|
||||
<p>
|
||||
<BaseButton
|
||||
:href="CALDAV_DOCS"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('user.settings.caldav.more') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {CALDAV_DOCS} from '@/urls'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
||||
import {success} from '@/message'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import CaldavTokenService from '@/services/caldavToken'
|
||||
import { formatDateShort } from '@/helpers/time/formatDate'
|
||||
import type {ICaldavToken} from '@/modelTypes/ICaldavToken'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const copy = useCopyToClipboard()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.settings.caldav.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
const service = shallowReactive(new CaldavTokenService())
|
||||
const tokens = ref<ICaldavToken[]>([])
|
||||
|
||||
service.getAll().then((result: ICaldavToken[]) => {
|
||||
tokens.value = result
|
||||
})
|
||||
|
||||
const newToken = ref<ICaldavToken>()
|
||||
async function createToken() {
|
||||
newToken.value = await service.create({}) as ICaldavToken
|
||||
tokens.value.push(newToken.value)
|
||||
}
|
||||
|
||||
async function deleteToken(token: ICaldavToken) {
|
||||
const r = await service.delete(token)
|
||||
tokens.value = tokens.value.filter(({id}) => id !== token.id)
|
||||
success(r)
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const username = computed(() => authStore.info?.username)
|
||||
const caldavUrl = computed(() => `${configStore.apiBase}/dav/principals/${username.value}/`)
|
||||
const caldavEnabled = computed(() => configStore.caldavEnabled)
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
</script>
|
83
frontend/src/views/user/settings/DataExport.vue
Normal file
83
frontend/src/views/user/settings/DataExport.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<card :title="$t('user.export.title')">
|
||||
<p>
|
||||
{{ $t('user.export.description') }}
|
||||
</p>
|
||||
<template v-if="isLocalUser">
|
||||
<p>
|
||||
{{ $t('user.export.descriptionPasswordRequired') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPasswordDataExport"
|
||||
>
|
||||
{{ $t('user.settings.currentPassword') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPasswordDataExport"
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
class="input"
|
||||
:class="{'is-danger': errPasswordRequired}"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup="() => errPasswordRequired = password === ''"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="errPasswordRequired"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.deletion.passwordRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<x-button
|
||||
:loading="dataExportService.loading"
|
||||
class="is-fullwidth mt-4"
|
||||
@click="requestDataExport()"
|
||||
>
|
||||
{{ $t('user.export.request') }}
|
||||
</x-button>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'UserSettingsDataExport'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import DataExportService from '@/services/dataExport'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const authStore = useAuthStore()
|
||||
|
||||
useTitle(() => `${t('user.export.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
const dataExportService = shallowReactive(new DataExportService())
|
||||
const password = ref('')
|
||||
const errPasswordRequired = ref(false)
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
const passwordInput = ref()
|
||||
|
||||
async function requestDataExport() {
|
||||
if (password.value === '' && isLocalUser.value) {
|
||||
errPasswordRequired.value = true
|
||||
passwordInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
await dataExportService.request(password.value)
|
||||
success({message: t('user.export.success')})
|
||||
password.value = ''
|
||||
}
|
||||
</script>
|
170
frontend/src/views/user/settings/Deletion.vue
Normal file
170
frontend/src/views/user/settings/Deletion.vue
Normal file
@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<card
|
||||
v-if="userDeletionEnabled"
|
||||
:title="$t('user.deletion.title')"
|
||||
>
|
||||
<template v-if="deletionScheduledAt !== null">
|
||||
<form @submit.prevent="cancelDeletion()">
|
||||
<p>
|
||||
{{
|
||||
$t('user.deletion.scheduled', {
|
||||
date: formatDateShort(deletionScheduledAt),
|
||||
dateSince: formatDateSince(deletionScheduledAt),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<template v-if="isLocalUser">
|
||||
<p>
|
||||
{{ $t('user.deletion.scheduledCancelText') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPasswordAccountDelete"
|
||||
>
|
||||
{{ $t('user.settings.currentPassword') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPasswordAccountDelete"
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
class="input"
|
||||
:class="{'is-danger': errPasswordRequired}"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup="() => errPasswordRequired = password === ''"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="errPasswordRequired"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.deletion.passwordRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else>
|
||||
{{ $t('user.deletion.scheduledCancelButton') }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<x-button
|
||||
:loading="accountDeleteService.loading"
|
||||
class="is-fullwidth mt-4"
|
||||
@click="cancelDeletion()"
|
||||
>
|
||||
{{ $t('user.deletion.scheduledCancelConfirm') }}
|
||||
</x-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>
|
||||
{{ $t('user.deletion.text1') }}
|
||||
</p>
|
||||
<form
|
||||
v-if="isLocalUser"
|
||||
@submit.prevent="deleteAccount()"
|
||||
>
|
||||
<p>
|
||||
{{ $t('user.deletion.text2') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPasswordAccountDelete"
|
||||
>
|
||||
{{ $t('user.settings.currentPassword') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPasswordAccountDelete"
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
class="input"
|
||||
:class="{'is-danger': errPasswordRequired}"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup="() => errPasswordRequired = password === ''"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="errPasswordRequired"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.deletion.passwordRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
<p v-else>
|
||||
{{ $t('user.deletion.text3') }}
|
||||
</p>
|
||||
|
||||
<x-button
|
||||
:loading="accountDeleteService.loading"
|
||||
class="is-fullwidth mt-4 is-danger"
|
||||
@click="deleteAccount()"
|
||||
>
|
||||
{{ $t('user.deletion.confirm') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'UserSettingsDeletion'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive, computed} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import AccountDeleteService from '@/services/accountDelete'
|
||||
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
||||
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.deletion.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
const accountDeleteService = shallowReactive(new AccountDeleteService())
|
||||
const password = ref('')
|
||||
const errPasswordRequired = ref(false)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const userDeletionEnabled = computed(() => configStore.userDeletionEnabled)
|
||||
const deletionScheduledAt = computed(() => parseDateOrNull(authStore.info?.deletionScheduledAt))
|
||||
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
|
||||
const passwordInput = ref()
|
||||
|
||||
async function deleteAccount() {
|
||||
if (isLocalUser.value && password.value === '') {
|
||||
errPasswordRequired.value = true
|
||||
passwordInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
await accountDeleteService.request(password.value)
|
||||
success({message: t('user.deletion.requestSuccess')})
|
||||
password.value = ''
|
||||
}
|
||||
|
||||
async function cancelDeletion() {
|
||||
if (isLocalUser.value && password.value === '') {
|
||||
errPasswordRequired.value = true
|
||||
passwordInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
await accountDeleteService.cancel(password.value)
|
||||
success({message: t('user.deletion.scheduledCancelSuccess')})
|
||||
authStore.refreshUserInfo()
|
||||
password.value = ''
|
||||
}
|
||||
</script>
|
77
frontend/src/views/user/settings/EmailUpdate.vue
Normal file
77
frontend/src/views/user/settings/EmailUpdate.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<card
|
||||
v-if="isLocalUser"
|
||||
: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
|
||||
id="newEmail"
|
||||
v-model="emailUpdate.newEmail"
|
||||
class="input"
|
||||
:placeholder="$t('user.auth.emailPlaceholder')"
|
||||
type="email"
|
||||
@keyup.enter="updateEmail"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPasswordEmail"
|
||||
>{{ $t('user.settings.currentPassword') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPasswordEmail"
|
||||
v-model="emailUpdate.password"
|
||||
class="input"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup.enter="updateEmail"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<x-button
|
||||
:loading="emailUpdateService.loading"
|
||||
class="is-fullwidth mt-4"
|
||||
@click="updateEmail"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'UserSettingsUpdateEmail' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {reactive, computed, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import EmailUpdateService from '@/services/emailUpdate'
|
||||
import EmailUpdateModel from '@/models/emailUpdate'
|
||||
import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.settings.updateEmailTitle')} - ${t('user.settings.title')}`)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
|
||||
const emailUpdate = reactive(new EmailUpdateModel())
|
||||
const emailUpdateService = shallowReactive(new EmailUpdateService())
|
||||
async function updateEmail() {
|
||||
await emailUpdateService.update(emailUpdate)
|
||||
success({message: t('user.settings.updateEmailSuccess')})
|
||||
}
|
||||
</script>
|
304
frontend/src/views/user/settings/General.vue
Normal file
304
frontend/src/views/user/settings/General.vue
Normal file
@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<card
|
||||
:title="$t('user.settings.general.title')"
|
||||
class="general-settings"
|
||||
:loading="loading"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
:for="`newName${id}`"
|
||||
>{{ $t('user.settings.general.name') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
:id="`newName${id}`"
|
||||
v-model="settings.name"
|
||||
class="input"
|
||||
:placeholder="$t('user.settings.general.newName')"
|
||||
type="text"
|
||||
@keyup.enter="updateSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
{{ $t('user.settings.general.defaultProject') }}
|
||||
</label>
|
||||
<ProjectSearch v-model="defaultProject" />
|
||||
</div>
|
||||
<div
|
||||
v-if="hasFilters"
|
||||
class="field"
|
||||
>
|
||||
<label class="label">
|
||||
{{ $t('user.settings.general.filterUsedOnOverview') }}
|
||||
</label>
|
||||
<ProjectSearch
|
||||
v-model="filterUsedInOverview"
|
||||
:saved-filters-only="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="settings.emailRemindersEnabled"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.emailReminders') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="settings.discoverableByName"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.discoverableByName') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="settings.discoverableByEmail"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.discoverableByEmail') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="settings.frontendSettings.playSoundWhenDone"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.playSoundWhenDone') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="settings.overdueTasksRemindersEnabled"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.overdueReminders') }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="settings.overdueTasksRemindersEnabled"
|
||||
class="field"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
for="overdueTasksReminderTime"
|
||||
>
|
||||
{{ $t('user.settings.general.overdueTasksRemindersTime') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="overdueTasksReminderTime"
|
||||
v-model="settings.overdueTasksRemindersTime"
|
||||
class="input"
|
||||
type="time"
|
||||
@keyup.enter="updateSettings"
|
||||
>
|
||||
</div>
|
||||
</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="settings.language">
|
||||
<option
|
||||
v-for="lang in availableLanguageOptions"
|
||||
:key="lang.code"
|
||||
:value="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="settings.frontendSettings.quickAddMagicMode">
|
||||
<option
|
||||
v-for="set in PrefixMode"
|
||||
:key="set"
|
||||
:value="set"
|
||||
>
|
||||
{{ $t(`user.settings.quickAddMagic.${set}`) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="is-flex is-align-items-center">
|
||||
<span>
|
||||
{{ $t('user.settings.appearance.title') }}
|
||||
</span>
|
||||
<div class="select ml-2">
|
||||
<select v-model="settings.frontendSettings.colorSchema">
|
||||
<!-- TODO: use the Vikunja logo in color scheme as option buttons -->
|
||||
<option
|
||||
v-for="(title, schemeId) in colorSchemeSettings"
|
||||
:key="schemeId"
|
||||
:value="schemeId"
|
||||
>
|
||||
{{ title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="is-flex is-align-items-center">
|
||||
<span>
|
||||
{{ $t('user.settings.general.timezone') }}
|
||||
</span>
|
||||
<div class="select ml-2">
|
||||
<select v-model="settings.timezone">
|
||||
<option
|
||||
v-for="tz in availableTimezones"
|
||||
:key="tz"
|
||||
>
|
||||
{{ tz }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
v-cy="'saveGeneralSettings'"
|
||||
:loading="loading"
|
||||
class="is-fullwidth mt-4"
|
||||
@click="updateSettings()"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'UserSettingsGeneral'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, watch, ref} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {PrefixMode} from '@/modules/parseTaskText'
|
||||
|
||||
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||
|
||||
import {SUPPORTED_LOCALES} from '@/i18n'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
import {AuthenticatedHTTPFactory} from '@/helpers/fetcher'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import type {IUserSettings} from '@/modelTypes/IUserSettings'
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.settings.general.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
const DEFAULT_PROJECT_ID = 0
|
||||
|
||||
const colorSchemeSettings = computed(() => ({
|
||||
light: t('user.settings.appearance.colorScheme.light'),
|
||||
auto: t('user.settings.appearance.colorScheme.system'),
|
||||
dark: t('user.settings.appearance.colorScheme.dark'),
|
||||
}))
|
||||
|
||||
function useAvailableTimezones() {
|
||||
const availableTimezones = ref([])
|
||||
|
||||
const HTTP = AuthenticatedHTTPFactory()
|
||||
HTTP.get('user/timezones')
|
||||
.then(r => {
|
||||
if (r.data) {
|
||||
availableTimezones.value = r.data.sort()
|
||||
return
|
||||
}
|
||||
|
||||
availableTimezones.value = []
|
||||
})
|
||||
|
||||
return availableTimezones
|
||||
}
|
||||
|
||||
const availableTimezones = useAvailableTimezones()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const settings = ref<IUserSettings>({
|
||||
...authStore.settings,
|
||||
frontendSettings: {
|
||||
// Sub objects get exported as read only as well, so we need to
|
||||
// explicitly spread the object here to allow modification
|
||||
...authStore.settings.frontendSettings,
|
||||
},
|
||||
})
|
||||
const id = ref(createRandomID())
|
||||
const availableLanguageOptions = ref(
|
||||
Object.entries(SUPPORTED_LOCALES)
|
||||
.map(l => ({code: l[0], title: l[1]}))
|
||||
.sort((a, b) => a.title.localeCompare(b.title)),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => authStore.settings,
|
||||
() => {
|
||||
// Only set setting if we don't have edited values yet to avoid overriding
|
||||
if (Object.keys(settings.value).length !== 0) {
|
||||
return
|
||||
}
|
||||
settings.value = {...authStore.settings}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const defaultProject = computed({
|
||||
get: () => projectStore.projects[settings.value.defaultProjectId],
|
||||
set(l) {
|
||||
settings.value.defaultProjectId = l ? l.id : DEFAULT_PROJECT_ID
|
||||
},
|
||||
})
|
||||
const filterUsedInOverview = computed({
|
||||
get: () => projectStore.projects[settings.value.frontendSettings.filterIdUsedOnOverview],
|
||||
set(l) {
|
||||
settings.value.frontendSettings.filterIdUsedOnOverview = l ? l.id : null
|
||||
},
|
||||
})
|
||||
const hasFilters = computed(() => typeof projectStore.projectsArray.find(p => isSavedFilter(p)) !== 'undefined')
|
||||
const loading = computed(() => authStore.isLoadingGeneralSettings)
|
||||
|
||||
async function updateSettings() {
|
||||
await authStore.saveUserSettings({
|
||||
settings: {...settings.value},
|
||||
})
|
||||
}
|
||||
</script>
|
105
frontend/src/views/user/settings/PasswordUpdate.vue
Normal file
105
frontend/src/views/user/settings/PasswordUpdate.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<card
|
||||
v-if="isLocalUser"
|
||||
: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
|
||||
id="newPassword"
|
||||
v-model="passwordUpdate.newPassword"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||
type="password"
|
||||
@keyup.enter="updatePassword"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="newPasswordConfirm"
|
||||
>{{ $t('user.settings.newPasswordConfirm') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="newPasswordConfirm"
|
||||
v-model="passwordConfirm"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||
type="password"
|
||||
@keyup.enter="updatePassword"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPassword"
|
||||
>{{ $t('user.settings.currentPassword') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPassword"
|
||||
v-model="passwordUpdate.oldPassword"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup.enter="updatePassword"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<x-button
|
||||
:loading="passwordUpdateService.loading"
|
||||
class="is-fullwidth mt-4"
|
||||
@click="updatePassword"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'UserSettingsPasswordUpdate'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, shallowReactive, computed} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import PasswordUpdateService from '@/services/passwordUpdateService'
|
||||
import PasswordUpdateModel from '@/models/passwordUpdate'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success, error} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const passwordUpdateService = shallowReactive(new PasswordUpdateService())
|
||||
const passwordUpdate = reactive(new PasswordUpdateModel())
|
||||
const passwordConfirm = ref('')
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.settings.newPasswordTitle')} - ${t('user.settings.title')}`)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
|
||||
async function updatePassword() {
|
||||
if (passwordConfirm.value !== passwordUpdate.newPassword) {
|
||||
error({message: t('user.settings.passwordsDontMatch')})
|
||||
return
|
||||
}
|
||||
|
||||
await passwordUpdateService.update(passwordUpdate)
|
||||
success({message: t('user.settings.passwordUpdateSuccess')})
|
||||
}
|
||||
</script>
|
171
frontend/src/views/user/settings/TOTP.vue
Normal file
171
frontend/src/views/user/settings/TOTP.vue
Normal file
@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<card
|
||||
v-if="totpEnabled"
|
||||
:title="$t('user.settings.totp.title')"
|
||||
>
|
||||
<x-button
|
||||
v-if="!totpEnrolled && totp.secret === ''"
|
||||
:loading="totpService.loading"
|
||||
@click="totpEnroll()"
|
||||
>
|
||||
{{ $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
|
||||
id="totpConfirmPasscode"
|
||||
v-model="totpConfirmPasscode"
|
||||
autocomplete="one-time-code"
|
||||
class="input"
|
||||
:placeholder="$t('user.settings.totp.passcodePlaceholder')"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
@keyup.enter="totpConfirm"
|
||||
>
|
||||
</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
|
||||
class="is-danger"
|
||||
@click="totpDisableForm = true"
|
||||
>
|
||||
{{ $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
|
||||
id="currentPassword"
|
||||
v-model="totpDisablePassword"
|
||||
v-focus
|
||||
class="input"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup.enter="totpDisable"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<x-button
|
||||
class="is-danger"
|
||||
@click="totpDisable"
|
||||
>
|
||||
{{ $t('user.settings.totp.disable') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
variant="tertiary"
|
||||
class="ml-2"
|
||||
@click="totpDisableForm = false"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</template>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'UserSettingsTotp' }
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import TotpService from '@/services/totp'
|
||||
import TotpModel from '@/models/totp'
|
||||
|
||||
import {success} from '@/message'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import type {ITotp} from '@/modelTypes/ITotp'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.settings.totp.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
|
||||
const totpService = shallowReactive(new TotpService())
|
||||
const totp = ref<ITotp>(new TotpModel())
|
||||
const totpQR = ref('')
|
||||
const totpEnrolled = ref(false)
|
||||
const totpConfirmPasscode = ref('')
|
||||
const totpDisableForm = ref(false)
|
||||
const totpDisablePassword = ref('')
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const totpEnabled = computed(() => configStore.totpEnabled)
|
||||
|
||||
totpStatus()
|
||||
|
||||
async function totpStatus() {
|
||||
if (!totpEnabled.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
totp.value = await totpService.get()
|
||||
totpSetQrCode()
|
||||
} catch(e) {
|
||||
// Error code 1016 means totp is not enabled, we don't need an error in that case.
|
||||
if (e.response?.data?.code === 1016) {
|
||||
totpEnrolled.value = false
|
||||
return
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function totpSetQrCode() {
|
||||
const qr = await totpService.qrcode()
|
||||
totpQR.value = window.URL.createObjectURL(qr)
|
||||
}
|
||||
|
||||
async function totpEnroll() {
|
||||
totp.value = await totpService.enroll()
|
||||
totpEnrolled.value = true
|
||||
totpSetQrCode()
|
||||
}
|
||||
|
||||
async function totpConfirm() {
|
||||
await totpService.enable({passcode: totpConfirmPasscode.value})
|
||||
totp.value.enabled = true
|
||||
success({message: t('user.settings.totp.confirmSuccess')})
|
||||
}
|
||||
|
||||
async function totpDisable() {
|
||||
await totpService.disable({password: totpDisablePassword.value})
|
||||
totpEnrolled.value = false
|
||||
totp.value = new TotpModel()
|
||||
success({message: t('user.settings.totp.disableSuccess')})
|
||||
}
|
||||
</script>
|
Reference in New Issue
Block a user