1
0

chore: move frontend files

This commit is contained in:
kolaente
2024-02-07 14:56:56 +01:00
parent 447641c222
commit fc4676315d
606 changed files with 0 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>