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