chore: move frontend files
This commit is contained in:
12
frontend/src/views/404.vue
Normal file
12
frontend/src/views/404.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="content has-text-centered">
|
||||
<h1>{{ $t('404.title') }}</h1>
|
||||
<p>{{ $t('404.text') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
useTitle(() => '404')
|
||||
</script>
|
43
frontend/src/views/About.vue
Normal file
43
frontend/src/views/About.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<modal
|
||||
transition-name="fade"
|
||||
variant="hint-modal"
|
||||
@close="$router.back()"
|
||||
>
|
||||
<card
|
||||
class="has-no-shadow"
|
||||
:title="$t('about.title')"
|
||||
:has-close="true"
|
||||
:padding="false"
|
||||
@close="$router.back()"
|
||||
>
|
||||
<div class="p-4">
|
||||
<p>
|
||||
{{ $t('about.frontendVersion', {version: frontendVersion}) }}
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('about.apiVersion', {version: apiVersion}) }}
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<x-button
|
||||
variant="secondary"
|
||||
@click.prevent.stop="$router.back()"
|
||||
>
|
||||
{{ $t('misc.close') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</card>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
import {VERSION} from '@/version.json'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const apiVersion = computed(() => configStore.version)
|
||||
const frontendVersion = VERSION
|
||||
</script>
|
111
frontend/src/views/Home.vue
Normal file
111
frontend/src/views/Home.vue
Normal file
@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="content has-text-centered">
|
||||
<h2 v-if="salutation">
|
||||
{{ salutation }}
|
||||
</h2>
|
||||
|
||||
<Message
|
||||
v-if="deletionScheduledAt !== null"
|
||||
variant="danger"
|
||||
class="mb-4"
|
||||
>
|
||||
{{
|
||||
$t('user.deletion.scheduled', {
|
||||
date: formatDateShort(deletionScheduledAt),
|
||||
dateSince: formatDateSince(deletionScheduledAt),
|
||||
})
|
||||
}}
|
||||
<router-link :to="{name: 'user.settings', hash: '#deletion'}">
|
||||
{{ $t('user.deletion.scheduledCancel') }}
|
||||
</router-link>
|
||||
</Message>
|
||||
<AddTask
|
||||
class="is-max-width-desktop"
|
||||
@taskAdded="updateTaskKey"
|
||||
/>
|
||||
<template v-if="!hasTasks && !loading && migratorsEnabled">
|
||||
<p class="mt-4">
|
||||
{{ $t('home.project.importText') }}
|
||||
</p>
|
||||
<x-button
|
||||
:to="{ name: 'migrate.start' }"
|
||||
:shadow="false"
|
||||
>
|
||||
{{ $t('home.project.import') }}
|
||||
</x-button>
|
||||
</template>
|
||||
<div
|
||||
v-if="projectHistory.length > 0"
|
||||
class="is-max-width-desktop has-text-left mt-4"
|
||||
>
|
||||
<h3>{{ $t('home.lastViewed') }}</h3>
|
||||
<ProjectCardGrid
|
||||
v-cy="'projectCardGrid'"
|
||||
:projects="projectHistory"
|
||||
/>
|
||||
</div>
|
||||
<ShowTasks
|
||||
v-if="projectStore.hasProjects"
|
||||
:key="showTasksKey"
|
||||
class="show-tasks"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed} from 'vue'
|
||||
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import ShowTasks from '@/views/tasks/ShowTasks.vue'
|
||||
import ProjectCardGrid from '@/components/project/partials/ProjectCardGrid.vue'
|
||||
import AddTask from '@/components/tasks/add-task.vue'
|
||||
|
||||
import {getHistory} from '@/modules/projectHistory'
|
||||
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
||||
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
|
||||
import {useDaytimeSalutation} from '@/composables/useDaytimeSalutation'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
const salutation = useDaytimeSalutation()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const projectStore = useProjectStore()
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
const projectHistory = computed(() => {
|
||||
// If we don't check this, it tries to load the project background right after logging out
|
||||
if(!authStore.authenticated) {
|
||||
return []
|
||||
}
|
||||
|
||||
return getHistory()
|
||||
.map(l => projectStore.projects[l.id])
|
||||
.filter(l => Boolean(l))
|
||||
})
|
||||
|
||||
const migratorsEnabled = computed(() => configStore.availableMigrators?.length > 0)
|
||||
const hasTasks = computed(() => baseStore.hasTasks)
|
||||
const loading = computed(() => taskStore.isLoading)
|
||||
const deletionScheduledAt = computed(() => parseDateOrNull(authStore.info?.deletionScheduledAt))
|
||||
|
||||
// This is to reload the tasks list after adding a new task through the global task add.
|
||||
// FIXME: Should use pinia (somehow?)
|
||||
const showTasksKey = ref(0)
|
||||
|
||||
function updateTaskKey() {
|
||||
showTasksKey.value++
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.show-tasks {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
27
frontend/src/views/filters/FilterDelete.vue
Normal file
27
frontend/src/views/filters/FilterDelete.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<modal
|
||||
@close="$router.back()"
|
||||
@submit="deleteFilter()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('filters.delete.header') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>{{ $t('filters.delete.text') }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {useSavedFilter} from '@/services/savedFilter'
|
||||
|
||||
const {
|
||||
projectId,
|
||||
} = defineProps<{
|
||||
projectId: IProject['id'],
|
||||
}>()
|
||||
|
||||
const {deleteFilter} = useSavedFilter(projectId)
|
||||
</script>
|
92
frontend/src/views/filters/FilterEdit.vue
Normal file
92
frontend/src/views/filters/FilterEdit.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('filters.edit.title')"
|
||||
primary-icon=""
|
||||
:primary-label="$t('misc.save')"
|
||||
:tertiary="$t('misc.delete')"
|
||||
@primary="saveFilterWithValidation"
|
||||
@tertiary="$router.push({ name: 'filter.settings.delete', params: { id: projectId } })"
|
||||
>
|
||||
<form @submit.prevent="saveFilterWithValidation()">
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="title"
|
||||
>{{ $t('filters.attributes.title') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="Title"
|
||||
v-model="filter.title"
|
||||
v-focus
|
||||
:class="{ 'disabled': filterService.loading, 'is-danger': !titleValid }"
|
||||
:disabled="filterService.loading || undefined"
|
||||
class="input"
|
||||
:placeholder="$t('filters.attributes.titlePlaceholder')"
|
||||
type="text"
|
||||
@focusout="validateTitleField"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!titleValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('filters.create.titleRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="description"
|
||||
>{{ $t('filters.attributes.description') }}</label>
|
||||
<div class="control">
|
||||
<Editor
|
||||
id="description"
|
||||
v-model="filter.description"
|
||||
:class="{ 'disabled': filterService.loading}"
|
||||
:disabled="filterService.loading"
|
||||
:placeholder="$t('filters.attributes.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="filters"
|
||||
>{{ $t('filters.title') }}</label>
|
||||
<div class="control">
|
||||
<Filters
|
||||
v-model="filters"
|
||||
:class="{ 'disabled': filterService.loading}"
|
||||
:disabled="filterService.loading"
|
||||
class="has-no-shadow has-no-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import Filters from '@/components/project/partials/filters.vue'
|
||||
|
||||
import {useSavedFilter} from '@/services/savedFilter'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const {
|
||||
projectId,
|
||||
} = defineProps<{
|
||||
projectId: IProject['id'],
|
||||
}>()
|
||||
|
||||
const {
|
||||
saveFilterWithValidation,
|
||||
filter,
|
||||
filters,
|
||||
filterService,
|
||||
titleValid,
|
||||
validateTitleField,
|
||||
} = useSavedFilter(projectId)
|
||||
</script>
|
97
frontend/src/views/filters/FilterNew.vue
Normal file
97
frontend/src/views/filters/FilterNew.vue
Normal file
@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<modal
|
||||
variant="hint-modal"
|
||||
@close="$router.back()"
|
||||
>
|
||||
<card
|
||||
class="has-no-shadow"
|
||||
:title="$t('filters.create.title')"
|
||||
>
|
||||
<p>
|
||||
{{ $t('filters.create.description') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="title"
|
||||
>{{ $t('filters.attributes.title') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="Title"
|
||||
v-model="filter.title"
|
||||
v-focus
|
||||
:class="{ 'disabled': filterService.loading, 'is-danger': !titleValid }"
|
||||
:disabled="filterService.loading || undefined"
|
||||
class="input"
|
||||
:placeholder="$t('filters.attributes.titlePlaceholder')"
|
||||
type="text"
|
||||
@focusout="validateTitleField"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!titleValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('filters.create.titleRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="description"
|
||||
>{{ $t('filters.attributes.description') }}</label>
|
||||
<div class="control">
|
||||
<Editor
|
||||
id="description"
|
||||
:key="filter.id"
|
||||
v-model="filter.description"
|
||||
:class="{ 'disabled': filterService.loading}"
|
||||
:disabled="filterService.loading"
|
||||
:placeholder="$t('filters.attributes.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="filters"
|
||||
>{{ $t('filters.title') }}</label>
|
||||
<div class="control">
|
||||
<Filters
|
||||
v-model="filters"
|
||||
:class="{ 'disabled': filterService.loading}"
|
||||
:disabled="filterService.loading"
|
||||
class="has-no-shadow has-no-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<x-button
|
||||
:loading="filterService.loading"
|
||||
:disabled="filterService.loading || !titleValid"
|
||||
class="is-fullwidth"
|
||||
@click="createFilterWithValidation()"
|
||||
>
|
||||
{{ $t('filters.create.action') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</card>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
import Filters from '@/components/project/partials/filters.vue'
|
||||
|
||||
import {useSavedFilter} from '@/services/savedFilter'
|
||||
|
||||
const {
|
||||
filter,
|
||||
filters,
|
||||
createFilterWithValidation,
|
||||
filterService,
|
||||
titleValid,
|
||||
validateTitleField,
|
||||
} = useSavedFilter()
|
||||
</script>
|
210
frontend/src/views/labels/ListLabels.vue
Normal file
210
frontend/src/views/labels/ListLabels.vue
Normal file
@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'is-loading': loading}"
|
||||
class="loader-container"
|
||||
>
|
||||
<x-button
|
||||
:to="{name:'labels.create'}"
|
||||
class="is-pulled-right"
|
||||
icon="plus"
|
||||
>
|
||||
{{ $t('label.create.header') }}
|
||||
</x-button>
|
||||
|
||||
<div class="content">
|
||||
<h1>{{ $t('label.manage') }}</h1>
|
||||
<p v-if="Object.entries(labels).length > 0">
|
||||
{{ $t('label.description') }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="has-text-centered has-text-grey is-italic"
|
||||
>
|
||||
{{ $t('label.newCTA') }}
|
||||
<router-link :to="{name:'labels.create'}">
|
||||
{{ $t('label.create.title') }}.
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="labels-list column">
|
||||
<span
|
||||
v-for="l in labels"
|
||||
:key="l.id"
|
||||
:class="{'disabled': userInfo.id !== l.createdBy.id}"
|
||||
:style="{'background': l.hexColor, 'color': l.textColor}"
|
||||
class="tag"
|
||||
>
|
||||
<span
|
||||
v-if="userInfo.id !== l.createdBy.id"
|
||||
v-tooltip.bottom="$t('label.edit.forbidden')"
|
||||
>
|
||||
{{ l.title }}
|
||||
</span>
|
||||
<BaseButton
|
||||
v-else
|
||||
:style="{'color': l.textColor}"
|
||||
@click="editLabel(l)"
|
||||
>
|
||||
{{ l.title }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="userInfo.id === l.createdBy.id"
|
||||
class="delete is-small"
|
||||
@click="showDeleteDialoge(l)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLabelEdit"
|
||||
class="column is-4"
|
||||
>
|
||||
<card
|
||||
:title="$t('label.edit.header')"
|
||||
:has-close="true"
|
||||
@close="() => isLabelEdit = false"
|
||||
>
|
||||
<form @submit.prevent="editLabelSubmit()">
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('label.attributes.title') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
v-model="labelEditLabel.title"
|
||||
class="input"
|
||||
:placeholder="$t('label.attributes.titlePlaceholder')"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('label.attributes.description') }}</label>
|
||||
<div class="control">
|
||||
<Editor
|
||||
v-if="editorActive"
|
||||
v-model="labelEditLabel.description"
|
||||
:placeholder="$t('label.attributes.description')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('label.attributes.color') }}</label>
|
||||
<div class="control">
|
||||
<ColorPicker v-model="labelEditLabel.hexColor" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<x-button
|
||||
:loading="loading"
|
||||
class="is-fullwidth"
|
||||
@click="editLabelSubmit()"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
icon="trash-alt"
|
||||
class="is-danger"
|
||||
@click="showDeleteDialoge(labelEditLabel)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</card>
|
||||
</div>
|
||||
|
||||
<modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteLabel(labelToDelete)"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('task.label.delete.header') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('task.label.delete.text1') }}<br>
|
||||
{{ $t('task.label.delete.text2') }}
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, ref} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||
|
||||
import LabelModel from '@/models/label'
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
|
||||
import { useTitle } from '@/composables/useTitle'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const labelEditLabel = ref<ILabel>(new LabelModel())
|
||||
const isLabelEdit = ref(false)
|
||||
const editorActive = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const labelToDelete = ref<ILabel>(null)
|
||||
|
||||
useTitle(() => t('label.title'))
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userInfo = computed(() => authStore.info)
|
||||
|
||||
const labelStore = useLabelStore()
|
||||
labelStore.loadAllLabels()
|
||||
|
||||
// Alphabetically sort the labels
|
||||
const labels = computed(() => Object.values(labelStore.labels).sort((f, s) => f.title > s.title ? 1 : -1))
|
||||
const loading = computed(() => labelStore.isLoading)
|
||||
|
||||
function deleteLabel(label: ILabel) {
|
||||
showDeleteModal.value = false
|
||||
isLabelEdit.value = false
|
||||
return labelStore.deleteLabel(label)
|
||||
}
|
||||
|
||||
function editLabelSubmit() {
|
||||
return labelStore.updateLabel(labelEditLabel.value)
|
||||
}
|
||||
|
||||
function editLabel(label: ILabel) {
|
||||
if (label.createdBy.id !== userInfo.value.id) {
|
||||
return
|
||||
}
|
||||
// Duplicating the label to make sure it does not look like changes take effect immediatly as the label
|
||||
// object passed to this function here still has a reference to the store.
|
||||
labelEditLabel.value = new LabelModel({
|
||||
...label,
|
||||
// The model does not support passing dates into it directly so we need to convert them first
|
||||
created: +label.created,
|
||||
updated: +label.updated,
|
||||
})
|
||||
isLabelEdit.value = true
|
||||
|
||||
// This makes the editor trigger its mounted function again which makes it forget every input
|
||||
// it currently has in its textarea. This is a counter-hack to a hack inside of vue-easymde
|
||||
// which made it impossible to detect change from the outside. Therefore the component would
|
||||
// not update if new content from the outside was made available.
|
||||
// See https://github.com/NikulinIlya/vue-easymde/issues/3
|
||||
editorActive.value = false
|
||||
nextTick(() => editorActive.value = true)
|
||||
}
|
||||
|
||||
function showDeleteDialoge(label: ILabel) {
|
||||
labelToDelete.value = label
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
</script>
|
84
frontend/src/views/labels/NewLabel.vue
Normal file
84
frontend/src/views/labels/NewLabel.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('label.create.title')"
|
||||
:primary-disabled="label.title === ''"
|
||||
@create="newLabel()"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="labelTitle"
|
||||
>{{ $t('label.attributes.title') }}</label>
|
||||
<div
|
||||
class="control is-expanded"
|
||||
:class="{ 'is-loading': loading }"
|
||||
>
|
||||
<input
|
||||
id="labelTitle"
|
||||
v-model="label.title"
|
||||
v-focus
|
||||
:class="{ disabled: loading }"
|
||||
class="input"
|
||||
:placeholder="$t('label.attributes.titlePlaceholder')"
|
||||
type="text"
|
||||
@keyup.enter="newLabel()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="showError && label.title === ''"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('label.create.titleRequired') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('label.attributes.color') }}</label>
|
||||
<div class="control">
|
||||
<ColorPicker v-model="label.hexColor" />
|
||||
</div>
|
||||
</div>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeMount, ref} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useRouter} from 'vue-router'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||
|
||||
import LabelModel from '@/models/label'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success} from '@/message'
|
||||
import {getRandomColorHex} from '@/helpers/color/randomColor'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => t('label.create.title'))
|
||||
|
||||
const labelStore = useLabelStore()
|
||||
const label = ref(new LabelModel())
|
||||
|
||||
onBeforeMount(() => label.value.hexColor = getRandomColorHex())
|
||||
|
||||
const showError = ref(false)
|
||||
const loading = computed(() => labelStore.isLoading)
|
||||
|
||||
async function newLabel() {
|
||||
if (label.value.title === '') {
|
||||
showError.value = true
|
||||
return
|
||||
}
|
||||
showError.value = false
|
||||
|
||||
const newLabel = await labelStore.createLabel(label.value)
|
||||
router.push({
|
||||
name: 'labels.index',
|
||||
params: {id: newLabel.id},
|
||||
})
|
||||
success({message: t('label.create.success')})
|
||||
}
|
||||
</script>
|
57
frontend/src/views/migrate/Migration.vue
Normal file
57
frontend/src/views/migrate/Migration.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<h1>{{ $t('migrate.title') }}</h1>
|
||||
<p>{{ $t('migrate.description') }}</p>
|
||||
<div class="migration-services">
|
||||
<router-link
|
||||
v-for="{name, id, icon} in availableMigrators"
|
||||
:key="id"
|
||||
class="migration-service-link"
|
||||
:to="{name: 'migrate.service', params: {service: id}}"
|
||||
>
|
||||
<img
|
||||
class="migration-service-image"
|
||||
:alt="name"
|
||||
:src="icon"
|
||||
>
|
||||
{{ name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {MIGRATORS} from './migrators'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
useTitle(() => t('migrate.title'))
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const availableMigrators = computed(() => configStore.availableMigrators
|
||||
.map((id) => MIGRATORS[id])
|
||||
.filter((item) => Boolean(item)),
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.migration-services {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.migration-service-link {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
text-transform: capitalize;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.migration-service-image {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
313
frontend/src/views/migrate/MigrationHandler.vue
Normal file
313
frontend/src/views/migrate/MigrationHandler.vue
Normal file
@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<h1>{{ $t('migrate.titleService', {name: migrator.name}) }}</h1>
|
||||
<p>{{ $t('migrate.descriptionDo') }}</p>
|
||||
|
||||
<template v-if="message === '' && lastMigrationFinishedAt === null">
|
||||
<template v-if="isMigrating === false">
|
||||
<template v-if="migrator.isFileMigrator">
|
||||
<p>{{ $t('migrate.importUpload', {name: migrator.name}) }}</p>
|
||||
<input
|
||||
ref="uploadInput"
|
||||
class="is-hidden"
|
||||
type="file"
|
||||
@change="migrate"
|
||||
>
|
||||
<x-button
|
||||
:loading="migrationFileService.loading"
|
||||
:disabled="migrationFileService.loading || undefined"
|
||||
@click="uploadInput?.click()"
|
||||
>
|
||||
{{ $t('migrate.upload') }}
|
||||
</x-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>{{ $t('migrate.authorize', {name: migrator.name}) }}</p>
|
||||
<x-button
|
||||
:loading="migrationService.loading"
|
||||
:disabled="migrationService.loading || undefined"
|
||||
:href="authUrl"
|
||||
>
|
||||
{{ $t('migrate.getStarted') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="migration-in-progress-container"
|
||||
>
|
||||
<div class="migration-in-progress">
|
||||
<img
|
||||
:alt="migrator.name"
|
||||
:src="migrator.icon"
|
||||
class="logo"
|
||||
>
|
||||
<div class="progress-dots">
|
||||
<span
|
||||
v-for="i in progressDotsCount"
|
||||
:key="i"
|
||||
/>
|
||||
</div>
|
||||
<Logo class="logo" />
|
||||
</div>
|
||||
<p>{{ $t('migrate.inProgress') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="lastMigrationStartedAt && lastMigrationFinishedAt === null">
|
||||
<p>
|
||||
{{ $t('migrate.migrationInProgress') }}
|
||||
</p>
|
||||
<x-button :to="{name: 'home'}">
|
||||
{{ $t('home.goToOverview') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<div v-else-if="lastMigrationFinishedAt">
|
||||
<p>
|
||||
{{
|
||||
$t('migrate.alreadyMigrated1', {name: migrator.name, date: formatDateLong(lastMigrationFinishedAt)})
|
||||
}}<br>
|
||||
{{ $t('migrate.alreadyMigrated2') }}
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<x-button @click="migrate">
|
||||
{{ $t('migrate.confirm') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
:to="{name: 'home'}"
|
||||
variant="tertiary"
|
||||
class="has-text-danger"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Message
|
||||
v-if="migrator.isFileMigrator"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ message }}
|
||||
</Message>
|
||||
<Message
|
||||
v-else
|
||||
class="mb-4"
|
||||
>
|
||||
{{ $t('migrate.migrationStartedWillReciveEmail', {service: migrator.name}) }}
|
||||
</Message>
|
||||
|
||||
<x-button :to="{name: 'home'}">
|
||||
{{ $t('home.goToOverview') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
beforeRouteEnter(to) {
|
||||
if (MIGRATORS[to.params.service as string] === undefined) {
|
||||
return {name: 'not-found'}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import Logo from '@/assets/logo.svg?component'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
|
||||
import AbstractMigrationService, {type MigrationConfig} from '@/services/migrator/abstractMigration'
|
||||
import AbstractMigrationFileService from '@/services/migrator/abstractMigrationFile'
|
||||
|
||||
import {formatDateLong} from '@/helpers/time/formatDate'
|
||||
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
||||
|
||||
import {MIGRATORS, Migrator} from './migrators'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
service: string,
|
||||
code?: string,
|
||||
}>()
|
||||
|
||||
const PROGRESS_DOTS_COUNT = 8
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const progressDotsCount = ref(PROGRESS_DOTS_COUNT)
|
||||
const authUrl = ref('')
|
||||
const isMigrating = ref(false)
|
||||
const lastMigrationFinishedAt = ref<Date | null>(null)
|
||||
const lastMigrationStartedAt = ref<Date | null>(null)
|
||||
const message = ref('')
|
||||
const migratorAuthCode = ref('')
|
||||
|
||||
const migrator = computed<Migrator>(() => MIGRATORS[props.service])
|
||||
|
||||
// eslint-disable-next-line vue/no-ref-object-destructure
|
||||
const migrationService = shallowReactive(new AbstractMigrationService(migrator.value.id))
|
||||
// eslint-disable-next-line vue/no-ref-object-destructure
|
||||
const migrationFileService = shallowReactive(new AbstractMigrationFileService(migrator.value.id))
|
||||
|
||||
useTitle(() => t('migrate.titleService', {name: migrator.value.name}))
|
||||
|
||||
async function initMigration() {
|
||||
if (migrator.value.isFileMigrator) {
|
||||
return
|
||||
}
|
||||
|
||||
authUrl.value = await migrationService.getAuthUrl().then(({url}) => url)
|
||||
|
||||
const TOKEN_HASH_PREFIX = '#token='
|
||||
migratorAuthCode.value = location.hash.startsWith(TOKEN_HASH_PREFIX)
|
||||
? location.hash.substring(TOKEN_HASH_PREFIX.length)
|
||||
: props.code as string
|
||||
|
||||
if (!migratorAuthCode.value) {
|
||||
return
|
||||
}
|
||||
const {startedAt, finishedAt} = await migrationService.getStatus()
|
||||
if (startedAt) {
|
||||
lastMigrationStartedAt.value = parseDateOrNull(startedAt)
|
||||
}
|
||||
if (finishedAt) {
|
||||
lastMigrationFinishedAt.value = parseDateOrNull(finishedAt)
|
||||
if (lastMigrationFinishedAt.value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (lastMigrationStartedAt.value && lastMigrationFinishedAt.value === null) {
|
||||
// Migration already in progress
|
||||
return
|
||||
}
|
||||
|
||||
await migrate()
|
||||
}
|
||||
|
||||
initMigration()
|
||||
|
||||
const uploadInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function migrate() {
|
||||
isMigrating.value = true
|
||||
lastMigrationFinishedAt.value = null
|
||||
message.value = ''
|
||||
|
||||
let migrationConfig: MigrationConfig | File = {code: migratorAuthCode.value}
|
||||
|
||||
if (migrator.value.isFileMigrator) {
|
||||
if (uploadInput.value?.files?.length === 0) {
|
||||
return
|
||||
}
|
||||
migrationConfig = uploadInput.value?.files?.[0] as File
|
||||
}
|
||||
|
||||
try {
|
||||
const result = migrator.value.isFileMigrator
|
||||
? await migrationFileService.migrate(migrationConfig as File)
|
||||
: await migrationService.migrate(migrationConfig as MigrationConfig)
|
||||
message.value = result.message
|
||||
const projectStore = useProjectStore()
|
||||
return projectStore.loadProjects()
|
||||
} finally {
|
||||
isMigrating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.migration-in-progress-container {
|
||||
max-width: 400px;
|
||||
margin: 4rem auto 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.migration-in-progress {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
max-width: 400px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
max-height: 100px;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.progress-dots {
|
||||
height: 40px;
|
||||
width: 140px;
|
||||
overflow: visible;
|
||||
|
||||
span {
|
||||
transition: all 500ms ease;
|
||||
background: var(--grey-500);
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
display: inline-block;
|
||||
border-radius: 10px;
|
||||
animation: wave 2s ease infinite;
|
||||
margin-right: 5px;
|
||||
|
||||
&:nth-child(1) {
|
||||
animation-delay: 0;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
animation-delay: 300ms;
|
||||
}
|
||||
|
||||
&:nth-child(5) {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
&:nth-child(6) {
|
||||
animation-delay: 500ms;
|
||||
}
|
||||
|
||||
&:nth-child(7) {
|
||||
animation-delay: 600ms;
|
||||
}
|
||||
|
||||
&:nth-child(8) {
|
||||
animation-delay: 700ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 40%, 100% {
|
||||
transform: translate(0, 0);
|
||||
background-color: var(--primary);
|
||||
}
|
||||
10% {
|
||||
transform: translate(0, -15px);
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@keyframes wave {
|
||||
10% {
|
||||
transform: translate(0, 0);
|
||||
background-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
44
frontend/src/views/migrate/icons/microsoft-todo.svg
Normal file
44
frontend/src/views/migrate/icons/microsoft-todo.svg
Normal file
@ -0,0 +1,44 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1007.9 821.8">
|
||||
<defs>
|
||||
<radialGradient id="c" cx="410.2" cy="853.3" r="85" gradientTransform="rotate(45 546.8 785.4)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset=".5" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="e" cx="1051.1" cy="1265.9" r="85" gradientTransform="rotate(-135 769.6 767.5)" xlink:href="#c"/>
|
||||
<radialGradient id="h" cx="27.6" cy="2001.4" r="85" gradientTransform="scale(1 -1) rotate(45 2979.2 860.2)" xlink:href="#c"/>
|
||||
<linearGradient id="a" x1="700.8" y1="597" x2="749.8" y2="597" gradientTransform="matrix(.867 0 0 1.307 86.6 -142.3)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="f" x1="1880.8" y1="34.3" x2="1929.8" y2="34.3" gradientTransform="matrix(.867 0 0 -.796 -1446 767.1)" xlink:href="#a"/>
|
||||
<linearGradient id="i" x1="308.4" y1="811.6" x2="919.3" y2="200.7" gradientTransform="rotate(-45 613.8 506.2)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#2987e6"/>
|
||||
<stop offset="1" stop-color="#58c1f5"/>
|
||||
</linearGradient>
|
||||
<mask id="b" x="317.1" y="651.8" width="170" height="205.2" maskUnits="userSpaceOnUse">
|
||||
<path class="a" transform="rotate(45 546.8 845.5)" d="M367.7 871h85v85h-85z"/>
|
||||
</mask>
|
||||
<mask id="d" x="837.9" y="95.8" width="205.2" height="205.2" maskUnits="userSpaceOnUse">
|
||||
<path class="a" transform="rotate(-135 932.9 246)" d="M876 260h170v85H876z"/>
|
||||
</mask>
|
||||
<mask id="g" x="-35.2" y="299.5" width="205.2" height="205.2" maskUnits="userSpaceOnUse">
|
||||
<path class="a" transform="rotate(-45 -81.7 457.6)" d="M-22 463.7h170v85H-22z"/>
|
||||
</mask>
|
||||
<style>
|
||||
.a{fill:#fff}
|
||||
</style>
|
||||
</defs>
|
||||
<path transform="rotate(45 852.3 570)" style="fill:url(#a)" d="M694.4 269.8h42.5v736.5h-42.5z"/>
|
||||
<g style="mask:url(#b)">
|
||||
<circle cx="402.1" cy="736.8" r="85" style="fill:url(#c)"/>
|
||||
</g>
|
||||
<g style="mask:url(#d)">
|
||||
<circle cx="922.9" cy="216" r="85" style="fill:url(#e)"/>
|
||||
</g>
|
||||
<path transform="rotate(135 226.7 680)" style="fill:url(#f)" d="M185.3 515.6h42.5v448.5h-42.5z"/>
|
||||
<g style="mask:url(#g)">
|
||||
<circle cx="85" cy="419.7" r="85" style="fill:url(#h)"/>
|
||||
</g>
|
||||
<rect x="164.4" y="320" width="288" height="576" rx="42.5" transform="rotate(-45 163.7 559.5)" style="fill:#195abd"/>
|
||||
<rect x="469.8" y="74.2" width="288" height="864" rx="42.5" transform="rotate(45 750.5 438.2)" style="fill:url(#i)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
1
frontend/src/views/migrate/icons/ticktick.svg
Normal file
1
frontend/src/views/migrate/icons/ticktick.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 88 88" xmlns="http://www.w3.org/2000/svg" class="logo_1OKcB"><g fill="none" fill-rule="evenodd"><rect></rect><path d="M30.755 33.292l-7.34 8.935L40.798 56.48a5.782 5.782 0 008.182-.854l31.179-38.93-9.026-7.228L43.614 43.83l-12.86-10.538z" fill="#FFB000"></path><path d="M44 78.1C25.197 78.1 9.9 62.803 9.9 44S25.197 9.9 44 9.9V0C19.738 0 0 19.738 0 44s19.738 44 44 44 44-19.738 44-44h-9.9c0 18.803-15.297 34.1-34.1 34.1" fill="#4772FA"></path></g></svg>
|
After Width: | Height: | Size: 471 B |
6
frontend/src/views/migrate/icons/todoist.svg
Normal file
6
frontend/src/views/migrate/icons/todoist.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid">
|
||||
<path d="M224 0H32A32 32 0 0 0 0 32v192a32 32 0 0 0 32 32h192a32 32 0 0 0 32-32V32a32 32 0 0 0-32-32" fill="#E44332"/>
|
||||
<path d="m54.1 120.8 102.6-59.6c2.2-1.3 2.3-5.2-.2-6.6l-8.8-5.1a8 8 0 0 0-8 0c-1.2.8-83.1 48.3-85.8 50-3.3 1.8-7.4 1.8-10.6 0L0 74v21.6l43 25.2c3.8 2.2 7.5 2.1 11.1 0" fill="#FFF"/>
|
||||
<path d="M54.1 161.6 156.7 102c2.2-1.3 2.3-5.2-.2-6.6l-8.8-5.1a8 8 0 0 0-8 0l-85.8 50c-3.3 1.8-7.4 1.8-10.6 0L0 114.7v21.6l43 25.2c3.8 2.2 7.5 2.1 11.1 0" fill="#FFF"/>
|
||||
<path d="m54.1 205 102.6-59.6c2.2-1.3 2.3-5.2-.2-6.7l-8.8-5a8 8 0 0 0-8 0L54 183.6c-3.3 1.9-7.4 1.9-10.6 0L0 158.2v21.6L43 205c3.8 2.1 7.5 2 11.1 0" fill="#FFF"/>
|
||||
</svg>
|
After Width: | Height: | Size: 745 B |
11
frontend/src/views/migrate/icons/trello.svg
Normal file
11
frontend/src/views/migrate/icons/trello.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid">
|
||||
<defs>
|
||||
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="a">
|
||||
<stop stop-color="#0091E6" offset="0%"/>
|
||||
<stop stop-color="#0079BF" offset="100%"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect fill="url(#a)" width="256" height="256" rx="25"/>
|
||||
<rect fill="#FFF" x="144.6" y="33.3" width="78.1" height="112" rx="12"/>
|
||||
<rect fill="#FFF" x="33.3" y="33.3" width="78.1" height="176" rx="12"/>
|
||||
</svg>
|
After Width: | Height: | Size: 512 B |
BIN
frontend/src/views/migrate/icons/vikunja-file.png
Normal file
BIN
frontend/src/views/migrate/icons/vikunja-file.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.0 KiB |
BIN
frontend/src/views/migrate/icons/wunderlist.jpg
Normal file
BIN
frontend/src/views/migrate/icons/wunderlist.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.9 KiB |
52
frontend/src/views/migrate/migrators.ts
Normal file
52
frontend/src/views/migrate/migrators.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import wunderlistIcon from './icons/wunderlist.jpg'
|
||||
import todoistIcon from './icons/todoist.svg?url'
|
||||
import trelloIcon from './icons/trello.svg?url'
|
||||
import microsoftTodoIcon from './icons/microsoft-todo.svg?url'
|
||||
import vikunjaFileIcon from './icons/vikunja-file.png?url'
|
||||
import tickTickIcon from './icons/ticktick.svg?url'
|
||||
|
||||
export interface Migrator {
|
||||
id: string
|
||||
name: string
|
||||
isFileMigrator?: boolean
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface IMigratorRecord {
|
||||
[key: Migrator['id']]: Migrator
|
||||
}
|
||||
|
||||
export const MIGRATORS = {
|
||||
wunderlist: {
|
||||
id: 'wunderlist',
|
||||
name: 'Wunderlist',
|
||||
icon: wunderlistIcon,
|
||||
},
|
||||
todoist: {
|
||||
id: 'todoist',
|
||||
name: 'Todoist',
|
||||
icon: todoistIcon as string,
|
||||
},
|
||||
trello: {
|
||||
id: 'trello',
|
||||
name: 'Trello',
|
||||
icon: trelloIcon as string,
|
||||
},
|
||||
'microsoft-todo': {
|
||||
id: 'microsoft-todo',
|
||||
name: 'Microsoft Todo',
|
||||
icon: microsoftTodoIcon as string,
|
||||
},
|
||||
'vikunja-file': {
|
||||
id: 'vikunja-file',
|
||||
name: 'Vikunja Export',
|
||||
icon: vikunjaFileIcon,
|
||||
isFileMigrator: true,
|
||||
},
|
||||
ticktick: {
|
||||
id: 'ticktick',
|
||||
name: 'TickTick',
|
||||
icon: tickTickIcon as string,
|
||||
isFileMigrator: true,
|
||||
},
|
||||
} as const satisfies IMigratorRecord
|
109
frontend/src/views/project/ListProjects.vue
Normal file
109
frontend/src/views/project/ListProjects.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div
|
||||
v-cy="'projects-list'"
|
||||
class="content loader-container"
|
||||
:class="{'is-loading': loading}"
|
||||
>
|
||||
<header class="project-header">
|
||||
<Fancycheckbox
|
||||
v-model="showArchived"
|
||||
v-cy="'show-archived-check'"
|
||||
>
|
||||
{{ $t('project.showArchived') }}
|
||||
</Fancycheckbox>
|
||||
|
||||
<div class="action-buttons">
|
||||
<x-button
|
||||
:to="{name: 'filters.create'}"
|
||||
icon="filter"
|
||||
>
|
||||
{{ $t('filters.create.title') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
v-cy="'new-project'"
|
||||
:to="{name: 'project.create'}"
|
||||
icon="plus"
|
||||
>
|
||||
{{ $t('project.create.header') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ProjectCardGrid
|
||||
:projects="projects"
|
||||
:show-archived="showArchived"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import ProjectCardGrid from '@/components/project/partials/ProjectCardGrid.vue'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useStorage} from '@vueuse/core'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const {t} = useI18n()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
useTitle(() => t('project.title'))
|
||||
const showArchived = useStorage('showArchived', false)
|
||||
|
||||
const loading = computed(() => projectStore.isLoading)
|
||||
const projects = computed(() => {
|
||||
return showArchived.value
|
||||
? projectStore.projectsArray
|
||||
: projectStore.projectsArray.filter(({isArchived}) => !isArchived)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.project:not(:first-child) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.is-archived {
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--grey-500);
|
||||
color: $grey !important;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: $vikunja-font;
|
||||
background: var(--white-translucent);
|
||||
margin-left: .5rem;
|
||||
}
|
||||
</style>
|
102
frontend/src/views/project/NewProject.vue
Normal file
102
frontend/src/views/project/NewProject.vue
Normal file
@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('project.create.header')"
|
||||
:primary-disabled="project.title === ''"
|
||||
@create="createNewProject()"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="projectTitle"
|
||||
>{{ $t('project.title') }}</label>
|
||||
<div
|
||||
:class="{ 'is-loading': projectService.loading }"
|
||||
class="control"
|
||||
>
|
||||
<input
|
||||
v-model="project.title"
|
||||
v-focus
|
||||
:class="{ disabled: projectService.loading }"
|
||||
class="input"
|
||||
:placeholder="$t('project.create.titlePlaceholder')"
|
||||
type="text"
|
||||
name="projectTitle"
|
||||
@keyup.enter="createNewProject()"
|
||||
@keyup.esc="$router.back()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="showError && project.title === ''"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('project.create.addTitleRequired') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="projectStore.hasProjects"
|
||||
class="field"
|
||||
>
|
||||
<label class="label">{{ $t('project.parent') }}</label>
|
||||
<div class="control">
|
||||
<ProjectSearch v-model="parentProject" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('project.color') }}</label>
|
||||
<div class="control">
|
||||
<ColorPicker v-model="project.hexColor" />
|
||||
</div>
|
||||
</div>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, shallowReactive, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import ProjectService from '@/services/project'
|
||||
import ProjectModel from '@/models/project'
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||
|
||||
import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const props = defineProps<{
|
||||
parentProjectId?: number,
|
||||
}>()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
useTitle(() => t('project.create.header'))
|
||||
|
||||
const showError = ref(false)
|
||||
const project = reactive(new ProjectModel())
|
||||
const projectService = shallowReactive(new ProjectService())
|
||||
const projectStore = useProjectStore()
|
||||
const parentProject = ref<IProject | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.parentProjectId,
|
||||
() => parentProject.value = projectStore.projects[props.parentProjectId],
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
async function createNewProject() {
|
||||
if (project.title === '') {
|
||||
showError.value = true
|
||||
return
|
||||
}
|
||||
showError.value = false
|
||||
|
||||
if (parentProject.value) {
|
||||
project.parentProjectId = parentProject.value.id
|
||||
}
|
||||
|
||||
await projectStore.createProject(project)
|
||||
success({message: t('project.create.createdSuccess')})
|
||||
}
|
||||
</script>
|
219
frontend/src/views/project/ProjectGantt.vue
Normal file
219
frontend/src/views/project/ProjectGantt.vue
Normal file
@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<ProjectWrapper
|
||||
class="project-gantt"
|
||||
:project-id="filters.projectId"
|
||||
view-name="gantt"
|
||||
>
|
||||
<template #header>
|
||||
<card :has-content="false">
|
||||
<div class="gantt-options">
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="range"
|
||||
>{{ $t('project.gantt.range') }}</label>
|
||||
<div class="control">
|
||||
<Foo
|
||||
id="range"
|
||||
ref="flatPickerEl"
|
||||
v-model="flatPickerDateRange"
|
||||
:config="flatPickerConfig"
|
||||
class="input"
|
||||
:placeholder="$t('project.gantt.range')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hasDefaultFilters"
|
||||
class="field"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
for="range"
|
||||
>Reset</label>
|
||||
<div class="control">
|
||||
<x-button @click="setDefaultFilters">
|
||||
Reset
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
<Fancycheckbox
|
||||
v-model="filters.showTasksWithoutDates"
|
||||
is-block
|
||||
>
|
||||
{{ $t('project.gantt.showTasksWithoutDates') }}
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div class="gantt-chart-container">
|
||||
<card
|
||||
:has-content="false"
|
||||
:padding="false"
|
||||
class="has-overflow"
|
||||
>
|
||||
<GanttChart
|
||||
:filters="filters"
|
||||
:tasks="tasks"
|
||||
:is-loading="isLoading"
|
||||
:default-task-start-date="defaultTaskStartDate"
|
||||
:default-task-end-date="defaultTaskEndDate"
|
||||
@update:task="updateTask"
|
||||
/>
|
||||
<TaskForm
|
||||
v-if="canWrite"
|
||||
@createTask="addGanttTask"
|
||||
/>
|
||||
</card>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, toRefs} from 'vue'
|
||||
import type Flatpickr from 'flatpickr'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import type {RouteLocationNormalized} from 'vue-router'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
||||
|
||||
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
|
||||
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import TaskForm from '@/components/tasks/TaskForm.vue'
|
||||
|
||||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||
import {useGanttFilters} from './helpers/useGanttFilters'
|
||||
import {RIGHTS} from '@/constants/rights'
|
||||
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
type Options = Flatpickr.Options.Options
|
||||
|
||||
const props = defineProps<{route: RouteLocationNormalized}>()
|
||||
|
||||
const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttChart.vue'))
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const canWrite = computed(() => baseStore.currentProject?.maxRight > RIGHTS.READ)
|
||||
|
||||
const {route} = toRefs(props)
|
||||
const {
|
||||
filters,
|
||||
hasDefaultFilters,
|
||||
setDefaultFilters,
|
||||
tasks,
|
||||
isLoading,
|
||||
addTask,
|
||||
updateTask,
|
||||
} = useGanttFilters(route)
|
||||
|
||||
const DEFAULT_DATE_RANGE_DAYS = 7
|
||||
|
||||
const today = new Date()
|
||||
const defaultTaskStartDate: DateISO = new Date(today.setHours(0, 0, 0, 0)).toISOString()
|
||||
const defaultTaskEndDate: DateISO = new Date(new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate() + DEFAULT_DATE_RANGE_DAYS,
|
||||
).setHours(23, 59, 0, 0)).toISOString()
|
||||
|
||||
async function addGanttTask(title: ITask['title']) {
|
||||
return await addTask({
|
||||
title,
|
||||
projectId: filters.value.projectId,
|
||||
startDate: defaultTaskStartDate,
|
||||
endDate: defaultTaskEndDate,
|
||||
})
|
||||
}
|
||||
|
||||
const flatPickerEl = ref<typeof Foo | null>(null)
|
||||
const flatPickerDateRange = computed<Date[]>({
|
||||
get: () => ([
|
||||
new Date(filters.value.dateFrom),
|
||||
new Date(filters.value.dateTo),
|
||||
]),
|
||||
set(newVal) {
|
||||
const [dateFrom, dateTo] = newVal.map((date) => date?.toISOString())
|
||||
|
||||
// only set after whole range has been selected
|
||||
if (!dateTo) return
|
||||
|
||||
Object.assign(filters.value, {dateFrom, dateTo})
|
||||
},
|
||||
})
|
||||
|
||||
const initialDateRange = [filters.value.dateFrom, filters.value.dateTo]
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const flatPickerConfig = computed<Options>(() => ({
|
||||
altFormat: t('date.altFormatShort'),
|
||||
altInput: true,
|
||||
defaultDate: initialDateRange,
|
||||
enableTime: false,
|
||||
mode: 'range',
|
||||
locale: getFlatpickrLanguage(),
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gantt-chart-container {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gantt-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.link-share-view:not(.has-background)) .gantt-options {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
.card-content {
|
||||
padding: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0;
|
||||
width: 33%;
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-right: .5rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-top: .5rem;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
&, .input {
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.select,
|
||||
.select select {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: .9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
45
frontend/src/views/project/ProjectInfo.vue
Normal file
45
frontend/src/views/project/ProjectInfo.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<modal
|
||||
@close="$router.back()"
|
||||
>
|
||||
<card
|
||||
:title="project?.title"
|
||||
>
|
||||
<div
|
||||
v-if="htmlDescription !== ''"
|
||||
class="has-text-left"
|
||||
v-html="htmlDescription"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="is-italic"
|
||||
>
|
||||
{{ $t('project.noDescriptionAvailable') }}
|
||||
</p>
|
||||
</card>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const project = computed(() => projectStore.projects[props.projectId])
|
||||
const htmlDescription = computed(() => {
|
||||
const description = project.value?.description || ''
|
||||
if (description === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(description, {ADD_ATTR: ['target']})
|
||||
})
|
||||
</script>
|
873
frontend/src/views/project/ProjectKanban.vue
Normal file
873
frontend/src/views/project/ProjectKanban.vue
Normal file
@ -0,0 +1,873 @@
|
||||
<template>
|
||||
<ProjectWrapper
|
||||
class="project-kanban"
|
||||
:project-id="projectId"
|
||||
view-name="kanban"
|
||||
>
|
||||
<template #header>
|
||||
<div
|
||||
v-if="!isSavedFilter(project)"
|
||||
class="filter-container"
|
||||
>
|
||||
<div class="items">
|
||||
<FilterPopup v-model="params" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div class="kanban-view">
|
||||
<div
|
||||
:class="{ 'is-loading': loading && !oneTaskUpdating}"
|
||||
class="kanban kanban-bucket-container loader-container"
|
||||
>
|
||||
<draggable
|
||||
v-bind="DRAG_OPTIONS"
|
||||
:model-value="buckets"
|
||||
group="buckets"
|
||||
:disabled="!canWrite || newTaskInputFocused"
|
||||
tag="ul"
|
||||
:item-key="({id}: IBucket) => `bucket${id}`"
|
||||
:component-data="bucketDraggableComponentData"
|
||||
@update:modelValue="updateBuckets"
|
||||
@end="updateBucketPosition"
|
||||
@start="() => dragBucket = true"
|
||||
>
|
||||
<template #item="{element: bucket, index: bucketIndex }">
|
||||
<div
|
||||
class="bucket"
|
||||
:class="{'is-collapsed': collapsedBuckets[bucket.id]}"
|
||||
>
|
||||
<div
|
||||
class="bucket-header"
|
||||
@click="() => unCollapseBucket(bucket)"
|
||||
>
|
||||
<span
|
||||
v-if="project?.doneBucketId === bucket.id"
|
||||
v-tooltip="$t('project.kanban.doneBucketHint')"
|
||||
class="icon is-small has-text-success mr-2"
|
||||
>
|
||||
<icon icon="check-double" />
|
||||
</span>
|
||||
<h2
|
||||
class="title input"
|
||||
:contenteditable="(bucketTitleEditable && canWrite && !collapsedBuckets[bucket.id]) ? true : undefined"
|
||||
:spellcheck="false"
|
||||
@keydown.enter.prevent.stop="($event.target as HTMLElement).blur()"
|
||||
@keydown.esc.prevent.stop="($event.target as HTMLElement).blur()"
|
||||
@blur="saveBucketTitle(bucket.id, ($event.target as HTMLElement).textContent as string)"
|
||||
@click="focusBucketTitle"
|
||||
>
|
||||
{{ bucket.title }}
|
||||
</h2>
|
||||
<span
|
||||
v-if="bucket.limit > 0"
|
||||
:class="{'is-max': bucket.count >= bucket.limit}"
|
||||
class="limit"
|
||||
>
|
||||
{{ bucket.count }}/{{ bucket.limit }}
|
||||
</span>
|
||||
<Dropdown
|
||||
v-if="canWrite && !collapsedBuckets[bucket.id]"
|
||||
class="is-right options"
|
||||
trigger-icon="ellipsis-v"
|
||||
@close="() => showSetLimitInput = false"
|
||||
>
|
||||
<DropdownItem
|
||||
@click.stop="showSetLimitInput = true"
|
||||
>
|
||||
<div
|
||||
v-if="showSetLimitInput"
|
||||
class="field has-addons"
|
||||
>
|
||||
<div class="control">
|
||||
<input
|
||||
v-focus.always
|
||||
:value="bucket.limit"
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@keyup.esc="() => showSetLimitInput = false"
|
||||
@keyup.enter="() => showSetLimitInput = false"
|
||||
@input="(event) => setBucketLimit(bucket.id, parseInt((event.target as HTMLInputElement).value))"
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
v-cy="'setBucketLimit'"
|
||||
:disabled="bucket.limit < 0"
|
||||
:icon="['far', 'save']"
|
||||
:shadow="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
{{
|
||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('project.kanban.noLimit')})
|
||||
}}
|
||||
</template>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-tooltip="$t('project.kanban.doneBucketHintExtended')"
|
||||
:icon-class="{'has-text-success': bucket.id === project?.doneBucketId}"
|
||||
icon="check-double"
|
||||
@click.stop="toggleDoneBucket(bucket)"
|
||||
>
|
||||
{{ $t('project.kanban.doneBucket') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-tooltip="$t('project.kanban.defaultBucketHint')"
|
||||
:icon-class="{'has-text-primary': bucket.id === project.defaultBucketId}"
|
||||
icon="th"
|
||||
@click.stop="toggleDefaultBucket(bucket)"
|
||||
>
|
||||
{{ $t('project.kanban.defaultBucket') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
icon="angles-up"
|
||||
@click.stop="() => collapseBucket(bucket)"
|
||||
>
|
||||
{{ $t('project.kanban.collapse') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-tooltip="buckets.length <= 1 ? $t('project.kanban.deleteLast') : ''"
|
||||
:class="{'is-disabled': buckets.length <= 1}"
|
||||
icon-class="has-text-danger"
|
||||
icon="trash-alt"
|
||||
@click.stop="() => deleteBucketModal(bucket.id)"
|
||||
>
|
||||
{{ $t('misc.delete') }}
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
v-bind="DRAG_OPTIONS"
|
||||
:model-value="bucket.tasks"
|
||||
:group="{name: 'tasks', put: shouldAcceptDrop(bucket) && !dragBucket}"
|
||||
:disabled="!canWrite"
|
||||
:data-bucket-index="bucketIndex"
|
||||
tag="ul"
|
||||
:item-key="(task: ITask) => `bucket${bucket.id}-task${task.id}`"
|
||||
:component-data="getTaskDraggableTaskComponentData(bucket)"
|
||||
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
|
||||
@start="() => dragstart(bucket)"
|
||||
@end="updateTaskPosition"
|
||||
>
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="canWrite"
|
||||
class="bucket-footer"
|
||||
>
|
||||
<div
|
||||
v-if="showNewTaskInput[bucket.id]"
|
||||
class="field"
|
||||
>
|
||||
<div
|
||||
class="control"
|
||||
:class="{'is-loading': loading || taskLoading}"
|
||||
>
|
||||
<input
|
||||
v-model="newTaskText"
|
||||
v-focus.always
|
||||
class="input"
|
||||
:disabled="loading || taskLoading || undefined"
|
||||
:placeholder="$t('project.kanban.addTaskPlaceholder')"
|
||||
type="text"
|
||||
@focusout="toggleShowNewTaskInput(bucket.id)"
|
||||
@focusin="() => newTaskInputFocused = true"
|
||||
@keyup.enter="addTaskToBucket(bucket.id)"
|
||||
@keyup.esc="toggleShowNewTaskInput(bucket.id)"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="newTaskError[bucket.id] && newTaskText === ''"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('project.create.addTitleRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
<x-button
|
||||
v-else
|
||||
class="is-fullwidth has-text-centered"
|
||||
:shadow="false"
|
||||
icon="plus"
|
||||
variant="secondary"
|
||||
@click="toggleShowNewTaskInput(bucket.id)"
|
||||
>
|
||||
{{ bucket.tasks.length === 0 ? $t('project.kanban.addTask') : $t('project.kanban.addAnotherTask') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item="{element: task}">
|
||||
<div class="task-item">
|
||||
<KanbanCard
|
||||
class="kanban-card"
|
||||
:task="task"
|
||||
:loading="taskUpdating[task.id] ?? false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div
|
||||
v-if="canWrite && !loading && buckets.length > 0"
|
||||
class="bucket new-bucket"
|
||||
>
|
||||
<input
|
||||
v-if="showNewBucketInput"
|
||||
v-model="newBucketTitle"
|
||||
v-focus.always
|
||||
:class="{'is-loading': loading}"
|
||||
:disabled="loading || undefined"
|
||||
class="input"
|
||||
:placeholder="$t('project.kanban.addBucketPlaceholder')"
|
||||
type="text"
|
||||
@blur="() => showNewBucketInput = false"
|
||||
@keyup.enter="createNewBucket"
|
||||
@keyup.esc="($event.target as HTMLInputElement).blur()"
|
||||
>
|
||||
<x-button
|
||||
v-else
|
||||
:shadow="false"
|
||||
class="is-transparent is-fullwidth has-text-centered"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
@click="() => showNewBucketInput = true"
|
||||
>
|
||||
{{ $t('project.kanban.addBucket') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modal
|
||||
:enabled="showBucketDeleteModal"
|
||||
@close="showBucketDeleteModal = false"
|
||||
@submit="deleteBucket()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('project.kanban.deleteHeaderBucket') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('project.kanban.deleteBucketText1') }}<br>
|
||||
{{ $t('project.kanban.deleteBucketText2') }}
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, ref, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
import {klona} from 'klona/lite'
|
||||
|
||||
import {RIGHTS as Rights} from '@/constants/rights'
|
||||
import BucketModel from '@/models/bucket'
|
||||
|
||||
import type {IBucket} from '@/modelTypes/IBucket'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import {useKanbanStore} from '@/stores/kanban'
|
||||
|
||||
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||
import FilterPopup from '@/components/project/partials/filter-popup.vue'
|
||||
import KanbanCard from '@/components/tasks/partials/kanban-card.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
|
||||
import {getCollapsedBucketState, saveCollapsedBucketState, type CollapsedBuckets} from '@/helpers/saveCollapsedBucketState'
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
import {success} from '@/message'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const {
|
||||
projectId = undefined,
|
||||
} = defineProps<{
|
||||
projectId: number,
|
||||
}>()
|
||||
|
||||
const DRAG_OPTIONS = {
|
||||
// sortable options
|
||||
animation: 150,
|
||||
ghostClass: 'ghost',
|
||||
dragClass: 'task-dragging',
|
||||
delayOnTouchOnly: true,
|
||||
delay: 150,
|
||||
} as const
|
||||
|
||||
const MIN_SCROLL_HEIGHT_PERCENT = 0.25
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const kanbanStore = useKanbanStore()
|
||||
const taskStore = useTaskStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const taskContainerRefs = ref<{[id: IBucket['id']]: HTMLElement}>({})
|
||||
|
||||
const drag = ref(false)
|
||||
const dragBucket = ref(false)
|
||||
const sourceBucket = ref(0)
|
||||
|
||||
const showBucketDeleteModal = ref(false)
|
||||
const bucketToDelete = ref(0)
|
||||
const bucketTitleEditable = ref(false)
|
||||
|
||||
const newTaskText = ref('')
|
||||
const showNewTaskInput = ref<{[id: IBucket['id']]: boolean}>({})
|
||||
|
||||
const newBucketTitle = ref('')
|
||||
const showNewBucketInput = ref(false)
|
||||
const newTaskError = ref<{[id: IBucket['id']]: boolean}>({})
|
||||
const newTaskInputFocused = ref(false)
|
||||
|
||||
const showSetLimitInput = ref(false)
|
||||
const collapsedBuckets = ref<CollapsedBuckets>({})
|
||||
|
||||
// We're using this to show the loading animation only at the task when updating it
|
||||
const taskUpdating = ref<{[id: ITask['id']]: boolean}>({})
|
||||
const oneTaskUpdating = ref(false)
|
||||
|
||||
const params = ref({
|
||||
filter_by: [],
|
||||
filter_value: [],
|
||||
filter_comparator: [],
|
||||
filter_concat: 'and',
|
||||
})
|
||||
|
||||
const getTaskDraggableTaskComponentData = computed(() => (bucket: IBucket) => {
|
||||
return {
|
||||
ref: (el: HTMLElement) => setTaskContainerRef(bucket.id, el),
|
||||
onScroll: (event: Event) => handleTaskContainerScroll(bucket.id, bucket.projectId, event.target as HTMLElement),
|
||||
type: 'transition-group',
|
||||
name: !drag.value ? 'move-card' : null,
|
||||
class: [
|
||||
'tasks',
|
||||
{'dragging-disabled': !canWrite.value},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const bucketDraggableComponentData = computed(() => ({
|
||||
type: 'transition-group',
|
||||
name: !dragBucket.value ? 'move-bucket' : null,
|
||||
class: [
|
||||
'kanban-bucket-container',
|
||||
{'dragging-disabled': !canWrite.value},
|
||||
],
|
||||
}))
|
||||
const canWrite = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
|
||||
const project = computed(() => projectId ? projectStore.projects[projectId]: null)
|
||||
|
||||
const buckets = computed(() => kanbanStore.buckets)
|
||||
const loading = computed(() => kanbanStore.isLoading)
|
||||
|
||||
const taskLoading = computed(() => taskStore.isLoading)
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
params: params.value,
|
||||
projectId,
|
||||
}),
|
||||
({params}) => {
|
||||
if (projectId === undefined || Number(projectId) === 0) {
|
||||
return
|
||||
}
|
||||
collapsedBuckets.value = getCollapsedBucketState(projectId)
|
||||
kanbanStore.loadBucketsForProject({projectId, params})
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
)
|
||||
|
||||
function setTaskContainerRef(id: IBucket['id'], el: HTMLElement) {
|
||||
if (!el) return
|
||||
taskContainerRefs.value[id] = el
|
||||
}
|
||||
|
||||
function handleTaskContainerScroll(id: IBucket['id'], projectId: IProject['id'], el: HTMLElement) {
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
const scrollTopMax = el.scrollHeight - el.clientHeight
|
||||
const threshold = el.scrollTop + el.scrollTop * MIN_SCROLL_HEIGHT_PERCENT
|
||||
if (scrollTopMax > threshold) {
|
||||
return
|
||||
}
|
||||
|
||||
kanbanStore.loadNextTasksForBucket({
|
||||
projectId: projectId,
|
||||
params: params.value,
|
||||
bucketId: id,
|
||||
})
|
||||
}
|
||||
|
||||
function updateTasks(bucketId: IBucket['id'], tasks: IBucket['tasks']) {
|
||||
const bucket = kanbanStore.getBucketById(bucketId)
|
||||
|
||||
if (bucket === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
kanbanStore.setBucketById({
|
||||
...bucket,
|
||||
tasks,
|
||||
})
|
||||
}
|
||||
|
||||
async function updateTaskPosition(e) {
|
||||
drag.value = false
|
||||
|
||||
// While we could just pass the bucket index in through the function call, this would not give us the
|
||||
// new bucket id when a task has been moved between buckets, only the new bucket. Using the data-bucket-id
|
||||
// of the drop target works all the time.
|
||||
const bucketIndex = parseInt(e.to.dataset.bucketIndex)
|
||||
|
||||
const newBucket = buckets.value[bucketIndex]
|
||||
|
||||
// HACK:
|
||||
// this is a hacky workaround for a known problem of vue.draggable.next when using the footer slot
|
||||
// the problem: https://github.com/SortableJS/vue.draggable.next/issues/108
|
||||
// This hack doesn't remove the problem that the ghost item is still displayed below the footer
|
||||
// It just makes releasing the item possible.
|
||||
|
||||
// The newIndex of the event doesn't count in the elements of the footer slot.
|
||||
// This is why in case the length of the tasks is identical with the newIndex
|
||||
// we have to remove 1 to get the correct index.
|
||||
const newTaskIndex = newBucket.tasks.length === e.newIndex
|
||||
? e.newIndex - 1
|
||||
: e.newIndex
|
||||
|
||||
const task = newBucket.tasks[newTaskIndex]
|
||||
const oldBucket = buckets.value.find(b => b.id === task.bucketId)
|
||||
const taskBefore = newBucket.tasks[newTaskIndex - 1] ?? null
|
||||
const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null
|
||||
taskUpdating.value[task.id] = true
|
||||
|
||||
const newTask = klona(task) // cloning the task to avoid pinia store manipulation
|
||||
newTask.bucketId = newBucket.id
|
||||
newTask.kanbanPosition = calculateItemPosition(
|
||||
taskBefore !== null ? taskBefore.kanbanPosition : null,
|
||||
taskAfter !== null ? taskAfter.kanbanPosition : null,
|
||||
)
|
||||
if (
|
||||
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
|
||||
newBucket.id !== oldBucket.id
|
||||
) {
|
||||
newTask.done = project.value?.doneBucketId === newBucket.id
|
||||
}
|
||||
if (
|
||||
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
|
||||
newBucket.id !== oldBucket.id
|
||||
) {
|
||||
kanbanStore.setBucketById({
|
||||
...oldBucket,
|
||||
count: oldBucket.count - 1,
|
||||
})
|
||||
kanbanStore.setBucketById({
|
||||
...newBucket,
|
||||
count: newBucket.count + 1,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await taskStore.update(newTask)
|
||||
|
||||
// Make sure the first and second task don't both get position 0 assigned
|
||||
if(newTaskIndex === 0 && taskAfter !== null && taskAfter.kanbanPosition === 0) {
|
||||
const taskAfterAfter = newBucket.tasks[newTaskIndex + 2] ?? null
|
||||
const newTaskAfter = klona(taskAfter) // cloning the task to avoid pinia store manipulation
|
||||
newTaskAfter.bucketId = newBucket.id
|
||||
newTaskAfter.kanbanPosition = calculateItemPosition(
|
||||
0,
|
||||
taskAfterAfter !== null ? taskAfterAfter.kanbanPosition : null,
|
||||
)
|
||||
|
||||
await taskStore.update(newTaskAfter)
|
||||
}
|
||||
} finally {
|
||||
taskUpdating.value[task.id] = false
|
||||
oneTaskUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowNewTaskInput(bucketId: IBucket['id']) {
|
||||
showNewTaskInput.value[bucketId] = !showNewTaskInput.value[bucketId]
|
||||
newTaskInputFocused.value = false
|
||||
}
|
||||
|
||||
async function addTaskToBucket(bucketId: IBucket['id']) {
|
||||
if (newTaskText.value === '') {
|
||||
newTaskError.value[bucketId] = true
|
||||
return
|
||||
}
|
||||
newTaskError.value[bucketId] = false
|
||||
|
||||
const task = await taskStore.createNewTask({
|
||||
title: newTaskText.value,
|
||||
bucketId,
|
||||
projectId: project.value.id,
|
||||
})
|
||||
newTaskText.value = ''
|
||||
kanbanStore.addTaskToBucket(task)
|
||||
scrollTaskContainerToBottom(bucketId)
|
||||
}
|
||||
|
||||
function scrollTaskContainerToBottom(bucketId: IBucket['id']) {
|
||||
const bucketEl = taskContainerRefs.value[bucketId]
|
||||
if (!bucketEl) {
|
||||
return
|
||||
}
|
||||
bucketEl.scrollTop = bucketEl.scrollHeight
|
||||
}
|
||||
|
||||
async function createNewBucket() {
|
||||
if (newBucketTitle.value === '') {
|
||||
return
|
||||
}
|
||||
|
||||
await kanbanStore.createBucket(new BucketModel({
|
||||
title: newBucketTitle.value,
|
||||
projectId: project.value.id,
|
||||
}))
|
||||
newBucketTitle.value = ''
|
||||
showNewBucketInput.value = false
|
||||
}
|
||||
|
||||
function deleteBucketModal(bucketId: IBucket['id']) {
|
||||
if (buckets.value.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
bucketToDelete.value = bucketId
|
||||
showBucketDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteBucket() {
|
||||
try {
|
||||
await kanbanStore.deleteBucket({
|
||||
bucket: new BucketModel({
|
||||
id: bucketToDelete.value,
|
||||
projectId: project.value.id,
|
||||
}),
|
||||
params: params.value,
|
||||
})
|
||||
success({message: t('project.kanban.deleteBucketSuccess')})
|
||||
} finally {
|
||||
showBucketDeleteModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** This little helper allows us to drag a bucket around at the title without focusing on it right away. */
|
||||
async function focusBucketTitle(e: Event) {
|
||||
bucketTitleEditable.value = true
|
||||
await nextTick()
|
||||
const target = e.target as HTMLInputElement
|
||||
target.focus()
|
||||
}
|
||||
|
||||
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
|
||||
await kanbanStore.updateBucketTitle({
|
||||
id: bucketId,
|
||||
title: bucketTitle,
|
||||
})
|
||||
bucketTitleEditable.value = false
|
||||
}
|
||||
|
||||
function updateBuckets(value: IBucket[]) {
|
||||
// (1) buckets get updated in store and tasks positions get invalidated
|
||||
kanbanStore.setBuckets(value)
|
||||
}
|
||||
|
||||
// TODO: fix type
|
||||
function updateBucketPosition(e: {newIndex: number}) {
|
||||
// (2) bucket positon is changed
|
||||
dragBucket.value = false
|
||||
|
||||
const bucket = buckets.value[e.newIndex]
|
||||
const bucketBefore = buckets.value[e.newIndex - 1] ?? null
|
||||
const bucketAfter = buckets.value[e.newIndex + 1] ?? null
|
||||
|
||||
kanbanStore.updateBucket({
|
||||
id: bucket.id,
|
||||
position: calculateItemPosition(
|
||||
bucketBefore !== null ? bucketBefore.position : null,
|
||||
bucketAfter !== null ? bucketAfter.position : null,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async function setBucketLimit(bucketId: IBucket['id'], limit: number) {
|
||||
if (limit < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await kanbanStore.updateBucket({
|
||||
...kanbanStore.getBucketById(bucketId),
|
||||
limit,
|
||||
})
|
||||
success({message: t('project.kanban.bucketLimitSavedSuccess')})
|
||||
}
|
||||
|
||||
function shouldAcceptDrop(bucket: IBucket) {
|
||||
return (
|
||||
// When dragging from a bucket who has its limit reached, dragging should still be possible
|
||||
bucket.id === sourceBucket.value ||
|
||||
// If there is no limit set, dragging & dropping should always work
|
||||
bucket.limit === 0 ||
|
||||
// Disallow dropping to buckets which have their limit reached
|
||||
bucket.count < bucket.limit
|
||||
)
|
||||
}
|
||||
|
||||
function dragstart(bucket: IBucket) {
|
||||
drag.value = true
|
||||
sourceBucket.value = bucket.id
|
||||
}
|
||||
|
||||
async function toggleDefaultBucket(bucket: IBucket) {
|
||||
const defaultBucketId = project.value.defaultBucketId === bucket.id
|
||||
? 0
|
||||
: bucket.id
|
||||
|
||||
await projectStore.updateProject({
|
||||
...project.value,
|
||||
defaultBucketId,
|
||||
})
|
||||
success({message: t('project.kanban.defaultBucketSavedSuccess')})
|
||||
}
|
||||
|
||||
async function toggleDoneBucket(bucket: IBucket) {
|
||||
const doneBucketId = project.value?.doneBucketId === bucket.id
|
||||
? 0
|
||||
: bucket.id
|
||||
|
||||
await projectStore.updateProject({
|
||||
...project.value,
|
||||
doneBucketId,
|
||||
})
|
||||
success({message: t('project.kanban.doneBucketSavedSuccess')})
|
||||
}
|
||||
|
||||
function collapseBucket(bucket: IBucket) {
|
||||
collapsedBuckets.value[bucket.id] = true
|
||||
saveCollapsedBucketState(project.value.id, collapsedBuckets.value)
|
||||
}
|
||||
|
||||
function unCollapseBucket(bucket: IBucket) {
|
||||
if (!collapsedBuckets.value[bucket.id]) {
|
||||
return
|
||||
}
|
||||
|
||||
collapsedBuckets.value[bucket.id] = false
|
||||
saveCollapsedBucketState(project.value.id, collapsedBuckets.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
$ease-out: all .3s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
$bucket-width: 300px;
|
||||
$bucket-header-height: 60px;
|
||||
$bucket-right-margin: 1rem;
|
||||
|
||||
$crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 1rem - 1.5rem - 11px';
|
||||
$crazy-height-calculation-tasks: '#{$crazy-height-calculation} - 1rem - 2.5rem - 2rem - #{$button-height} - 1rem';
|
||||
$filter-container-height: '1rem - #{$switch-view-height}';
|
||||
|
||||
// FIXME:
|
||||
.app-content.project\.kanban, .app-content.task\.detail {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.kanban {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
height: calc(#{$crazy-height-calculation});
|
||||
margin: 0 -1.5rem;
|
||||
padding: 0 1.5rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
height: calc(#{$crazy-height-calculation} - #{$filter-container-height});
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
&-bucket-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
position: relative;
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 0.25rem;
|
||||
right: 0.5rem;
|
||||
bottom: 0.25rem;
|
||||
left: 0.5rem;
|
||||
border: 3px dashed var(--grey-300);
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.bucket {
|
||||
border-radius: $radius;
|
||||
position: relative;
|
||||
|
||||
margin: 0 $bucket-right-margin 0 0;
|
||||
max-height: calc(100% - 1rem); // 1rem spacing to the bottom
|
||||
min-height: 20px;
|
||||
width: $bucket-width;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; // Make sure the edges are always rounded
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
.tasks {
|
||||
overflow: hidden auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
background-color: var(--grey-100);
|
||||
padding: .25rem .5rem;
|
||||
|
||||
&:first-of-type {
|
||||
padding-top: .5rem;
|
||||
}
|
||||
&:last-of-type {
|
||||
padding-bottom: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.no-move {
|
||||
transition: transform 0s;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
&.new-bucket {
|
||||
// Because of reasons, this button ignores the margin we gave it to the right.
|
||||
// To make it still look like it has some, we modify the container to have a padding of 1rem,
|
||||
// which is the same as the margin it should have. Then we make the container itself bigger
|
||||
// to hide the fact we just made the button smaller.
|
||||
min-width: calc(#{$bucket-width} + 1rem);
|
||||
background: transparent;
|
||||
padding-right: 1rem;
|
||||
|
||||
.button {
|
||||
background: var(--grey-100);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-collapsed {
|
||||
align-self: flex-start;
|
||||
transform: rotate(90deg) translateY(-100%);
|
||||
transform-origin: top left;
|
||||
// Using negative margins instead of translateY here to make all other buckets fill the empty space
|
||||
margin-right: calc((#{$bucket-width} - #{$bucket-header-height} - #{$bucket-right-margin}) * -1);
|
||||
cursor: pointer;
|
||||
|
||||
.tasks, .bucket-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bucket-header {
|
||||
background-color: var(--grey-100);
|
||||
height: min-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: .5rem;
|
||||
height: $bucket-header-height;
|
||||
|
||||
.limit {
|
||||
padding: 0 .5rem;
|
||||
font-weight: bold;
|
||||
|
||||
&.is-max {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.title.input {
|
||||
height: auto;
|
||||
padding: .4rem .5rem;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.bucket-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
height: min-content;
|
||||
padding: .5rem;
|
||||
background-color: var(--grey-100);
|
||||
border-bottom-left-radius: $radius;
|
||||
border-bottom-right-radius: $radius;
|
||||
transform: none;
|
||||
|
||||
.button {
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: This does not seem to work
|
||||
.task-dragging {
|
||||
transform: rotateZ(3deg);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.move-card-move {
|
||||
transform: rotateZ(3deg);
|
||||
transition: transform $transition-duration;
|
||||
}
|
||||
|
||||
.move-card-leave-from,
|
||||
.move-card-leave-to,
|
||||
.move-card-leave-active {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
369
frontend/src/views/project/ProjectList.vue
Normal file
369
frontend/src/views/project/ProjectList.vue
Normal file
@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<ProjectWrapper
|
||||
class="project-list"
|
||||
:project-id="projectId"
|
||||
view-name="project"
|
||||
>
|
||||
<template #header>
|
||||
<div
|
||||
v-if="!isSavedFilter(project)"
|
||||
class="filter-container"
|
||||
>
|
||||
<div class="items">
|
||||
<div class="search">
|
||||
<div
|
||||
:class="{ hidden: !showTaskSearch }"
|
||||
class="field has-addons"
|
||||
>
|
||||
<div class="control has-icons-left has-icons-right">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
v-focus
|
||||
class="input"
|
||||
:placeholder="$t('misc.search')"
|
||||
type="text"
|
||||
@blur="hideSearchBar()"
|
||||
@keyup.enter="searchTasks"
|
||||
>
|
||||
<span class="icon is-left">
|
||||
<icon icon="search" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
:loading="loading"
|
||||
:shadow="false"
|
||||
@click="searchTasks"
|
||||
>
|
||||
{{ $t('misc.search') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
<x-button
|
||||
v-if="!showTaskSearch"
|
||||
icon="search"
|
||||
variant="secondary"
|
||||
@click="showTaskSearch = !showTaskSearch"
|
||||
/>
|
||||
</div>
|
||||
<FilterPopup
|
||||
v-model="params"
|
||||
@update:modelValue="prepareFiltersAndLoadTasks()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div
|
||||
:class="{ 'is-loading': loading }"
|
||||
class="loader-container is-max-width-desktop list-view"
|
||||
>
|
||||
<card
|
||||
:padding="false"
|
||||
:has-content="false"
|
||||
class="has-overflow"
|
||||
>
|
||||
<AddTask
|
||||
v-if="!project.isArchived && canWrite"
|
||||
ref="addTaskRef"
|
||||
class="list-view__add-task d-print-none"
|
||||
:default-position="firstNewPosition"
|
||||
@taskAdded="updateTaskList"
|
||||
/>
|
||||
|
||||
<Nothing v-if="ctaVisible && tasks.length === 0 && !loading">
|
||||
{{ $t('project.list.empty') }}
|
||||
<ButtonLink
|
||||
v-if="project.id > 0"
|
||||
@click="focusNewTaskInput()"
|
||||
>
|
||||
{{ $t('project.list.newTaskCta') }}
|
||||
</ButtonLink>
|
||||
</Nothing>
|
||||
|
||||
|
||||
<draggable
|
||||
v-if="tasks && tasks.length > 0"
|
||||
v-bind="DRAG_OPTIONS"
|
||||
v-model="tasks"
|
||||
group="tasks"
|
||||
handle=".handle"
|
||||
:disabled="!canWrite"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
:component-data="{
|
||||
class: {
|
||||
tasks: true,
|
||||
'dragging-disabled': !canWrite || isAlphabeticalSorting
|
||||
},
|
||||
type: 'transition-group'
|
||||
}"
|
||||
@start="() => drag = true"
|
||||
@end="saveTaskPosition"
|
||||
>
|
||||
<template #item="{element: t}">
|
||||
<SingleTaskInProject
|
||||
:show-list-color="false"
|
||||
:disabled="!canWrite"
|
||||
:can-mark-as-done="canWrite || isSavedFilter(project)"
|
||||
:the-task="t"
|
||||
:all-tasks="allTasks"
|
||||
@taskUpdated="updateTasks"
|
||||
>
|
||||
<template v-if="canWrite">
|
||||
<span class="icon handle">
|
||||
<icon icon="grip-lines" />
|
||||
</span>
|
||||
</template>
|
||||
</SingleTaskInProject>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<Pagination
|
||||
:total-pages="totalPages"
|
||||
:current-page="currentPage"
|
||||
/>
|
||||
</card>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'List' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, nextTick, onMounted, watch} from 'vue'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
|
||||
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||
import ButtonLink from '@/components/misc/ButtonLink.vue'
|
||||
import AddTask from '@/components/tasks/add-task.vue'
|
||||
import SingleTaskInProject from '@/components/tasks/partials/singleTaskInProject.vue'
|
||||
import FilterPopup from '@/components/project/partials/filter-popup.vue'
|
||||
import Nothing from '@/components/misc/nothing.vue'
|
||||
import Pagination from '@/components/misc/pagination.vue'
|
||||
import {ALPHABETICAL_SORT} from '@/components/project/partials/filters.vue'
|
||||
|
||||
import {useTaskList} from '@/composables/useTaskList'
|
||||
import {RIGHTS as Rights} from '@/constants/rights'
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const {
|
||||
projectId,
|
||||
} = defineProps<{
|
||||
projectId: IProject['id'],
|
||||
}>()
|
||||
|
||||
const ctaVisible = ref(false)
|
||||
const showTaskSearch = ref(false)
|
||||
|
||||
const drag = ref(false)
|
||||
const DRAG_OPTIONS = {
|
||||
animation: 100,
|
||||
ghostClass: 'task-ghost',
|
||||
} as const
|
||||
|
||||
const {
|
||||
tasks: allTasks,
|
||||
loading,
|
||||
totalPages,
|
||||
currentPage,
|
||||
loadTasks,
|
||||
searchTerm,
|
||||
params,
|
||||
sortByParam,
|
||||
} = useTaskList(() => projectId, {position: 'asc' })
|
||||
|
||||
const tasks = ref<ITask[]>([])
|
||||
watch(
|
||||
allTasks,
|
||||
() => {
|
||||
tasks.value = [...allTasks.value]
|
||||
if (projectId < 0) {
|
||||
return
|
||||
}
|
||||
const tasksById = {}
|
||||
tasks.value.forEach(t => tasksById[t.id] = true)
|
||||
|
||||
tasks.value = tasks.value.filter(t => {
|
||||
if (typeof t.relatedTasks?.parenttask === 'undefined') {
|
||||
return true
|
||||
}
|
||||
|
||||
// If the task is a subtask, make sure the parent task is available in the current view as well
|
||||
for (const pt of t.relatedTasks.parenttask) {
|
||||
if(typeof tasksById[pt.id] === 'undefined') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const isAlphabeticalSorting = computed(() => {
|
||||
return params.value.sort_by.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
|
||||
})
|
||||
|
||||
const firstNewPosition = computed(() => {
|
||||
if (tasks.value.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return calculateItemPosition(null, tasks.value[0].position)
|
||||
})
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const baseStore = useBaseStore()
|
||||
const project = computed(() => baseStore.currentProject)
|
||||
|
||||
const canWrite = computed(() => {
|
||||
return project.value.maxRight > Rights.READ && project.value.id > 0
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
ctaVisible.value = true
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function searchTasks() {
|
||||
// Only search if the search term changed
|
||||
if (route.query as unknown as string === searchTerm.value) {
|
||||
return
|
||||
}
|
||||
|
||||
router.push({
|
||||
name: 'project.list',
|
||||
query: {search: searchTerm.value},
|
||||
})
|
||||
}
|
||||
|
||||
function hideSearchBar() {
|
||||
// This is a workaround.
|
||||
// When clicking on the search button, @blur from the input is fired. If we
|
||||
// would then directly hide the whole search bar directly, no click event
|
||||
// from the button gets fired. To prevent this, we wait 200ms until we hide
|
||||
// everything so the button has a chance of firing the search event.
|
||||
setTimeout(() => {
|
||||
showTaskSearch.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const addTaskRef = ref<typeof AddTask | null>(null)
|
||||
function focusNewTaskInput() {
|
||||
addTaskRef.value?.focusTaskInput()
|
||||
}
|
||||
|
||||
function updateTaskList(task: ITask) {
|
||||
if (isAlphabeticalSorting.value ) {
|
||||
// reload tasks with current filter and sorting
|
||||
loadTasks()
|
||||
}
|
||||
else {
|
||||
allTasks.value = [
|
||||
task,
|
||||
...allTasks.value,
|
||||
]
|
||||
}
|
||||
|
||||
baseStore.setHasTasks(true)
|
||||
}
|
||||
|
||||
function updateTasks(updatedTask: ITask) {
|
||||
for (const t in tasks.value) {
|
||||
if (tasks.value[t].id === updatedTask.id) {
|
||||
tasks.value[t] = updatedTask
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTaskPosition(e) {
|
||||
drag.value = false
|
||||
|
||||
const task = tasks.value[e.newIndex]
|
||||
const taskBefore = tasks.value[e.newIndex - 1] ?? null
|
||||
const taskAfter = tasks.value[e.newIndex + 1] ?? null
|
||||
|
||||
const newTask = {
|
||||
...task,
|
||||
position: calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null),
|
||||
}
|
||||
|
||||
const updatedTask = await taskStore.update(newTask)
|
||||
tasks.value[e.newIndex] = updatedTask
|
||||
}
|
||||
|
||||
function prepareFiltersAndLoadTasks() {
|
||||
if(isAlphabeticalSorting.value) {
|
||||
sortByParam.value = {}
|
||||
sortByParam.value[ALPHABETICAL_SORT] = 'asc'
|
||||
}
|
||||
|
||||
loadTasks()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tasks {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.task-ghost {
|
||||
border-radius: $radius;
|
||||
background: var(--grey-100);
|
||||
border: 2px dashed var(--grey-300);
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.list-view__add-task {
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
|
||||
.link-share-view .card {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.control.has-icons-left .icon,
|
||||
.control.has-icons-right .icon {
|
||||
transition: all $transition;
|
||||
}
|
||||
|
||||
:deep(.single-task) {
|
||||
.handle {
|
||||
opacity: 1;
|
||||
transition: opacity $transition;
|
||||
margin-right: .25rem;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
@media(hover: hover) and (pointer: fine) {
|
||||
& .handle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
380
frontend/src/views/project/ProjectTable.vue
Normal file
380
frontend/src/views/project/ProjectTable.vue
Normal file
@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<ProjectWrapper
|
||||
class="project-table"
|
||||
:project-id="projectId"
|
||||
view-name="table"
|
||||
>
|
||||
<template #header>
|
||||
<div class="filter-container">
|
||||
<div class="items">
|
||||
<Popup>
|
||||
<template #trigger="{toggle}">
|
||||
<x-button
|
||||
icon="th"
|
||||
variant="secondary"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ $t('project.table.columns') }}
|
||||
</x-button>
|
||||
</template>
|
||||
<template #content="{isOpen}">
|
||||
<card
|
||||
class="columns-filter"
|
||||
:class="{'is-open': isOpen}"
|
||||
>
|
||||
<Fancycheckbox v-model="activeColumns.index">
|
||||
#
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.done">
|
||||
{{ $t('task.attributes.done') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.title">
|
||||
{{ $t('task.attributes.title') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.priority">
|
||||
{{ $t('task.attributes.priority') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.labels">
|
||||
{{ $t('task.attributes.labels') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.assignees">
|
||||
{{ $t('task.attributes.assignees') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.dueDate">
|
||||
{{ $t('task.attributes.dueDate') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.startDate">
|
||||
{{ $t('task.attributes.startDate') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.endDate">
|
||||
{{ $t('task.attributes.endDate') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.percentDone">
|
||||
{{ $t('task.attributes.percentDone') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.created">
|
||||
{{ $t('task.attributes.created') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.updated">
|
||||
{{ $t('task.attributes.updated') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox v-model="activeColumns.createdBy">
|
||||
{{ $t('task.attributes.createdBy') }}
|
||||
</Fancycheckbox>
|
||||
</card>
|
||||
</template>
|
||||
</Popup>
|
||||
<FilterPopup v-model="params" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div
|
||||
:class="{'is-loading': loading}"
|
||||
class="loader-container"
|
||||
>
|
||||
<card
|
||||
:padding="false"
|
||||
:has-content="false"
|
||||
>
|
||||
<div class="has-horizontal-overflow">
|
||||
<table class="table has-actions is-hoverable is-fullwidth mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="activeColumns.index">
|
||||
#
|
||||
<Sort
|
||||
:order="sortBy.index"
|
||||
@click="sort('index')"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="activeColumns.done">
|
||||
{{ $t('task.attributes.done') }}
|
||||
<Sort
|
||||
:order="sortBy.done"
|
||||
@click="sort('done')"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="activeColumns.title">
|
||||
{{ $t('task.attributes.title') }}
|
||||
<Sort
|
||||
:order="sortBy.title"
|
||||
@click="sort('title')"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="activeColumns.priority">
|
||||
{{ $t('task.attributes.priority') }}
|
||||
<Sort
|
||||
:order="sortBy.priority"
|
||||
@click="sort('priority')"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="activeColumns.labels">
|
||||
{{ $t('task.attributes.labels') }}
|
||||
</th>
|
||||
<th v-if="activeColumns.assignees">
|
||||
{{ $t('task.attributes.assignees') }}
|
||||
</th>
|
||||
<th v-if="activeColumns.dueDate">
|
||||
{{ $t('task.attributes.dueDate') }}
|
||||
<Sort
|
||||
:order="sortBy.due_date"
|
||||
@click="sort('due_date')"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="activeColumns.startDate">
|
||||
{{ $t('task.attributes.startDate') }}
|
||||
<Sort
|
||||
:order="sortBy.start_date"
|
||||
@click="sort('start_date')"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="activeColumns.endDate">
|
||||
{{ $t('task.attributes.endDate') }}
|
||||
<Sort
|
||||
:order="sortBy.end_date"
|
||||
@click="sort('end_date')"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="activeColumns.percentDone">
|
||||
{{ $t('task.attributes.percentDone') }}
|
||||
<Sort
|
||||
:order="sortBy.percent_done"
|
||||
@click="sort('percent_done')"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="activeColumns.created">
|
||||
{{ $t('task.attributes.created') }}
|
||||
<Sort
|
||||
:order="sortBy.created"
|
||||
@click="sort('created')"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="activeColumns.updated">
|
||||
{{ $t('task.attributes.updated') }}
|
||||
<Sort
|
||||
:order="sortBy.updated"
|
||||
@click="sort('updated')"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="activeColumns.createdBy">
|
||||
{{ $t('task.attributes.createdBy') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="t in tasks"
|
||||
:key="t.id"
|
||||
>
|
||||
<td v-if="activeColumns.index">
|
||||
<router-link :to="taskDetailRoutes[t.id]">
|
||||
<template v-if="t.identifier === ''">
|
||||
#{{ t.index }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t.identifier }}
|
||||
</template>
|
||||
</router-link>
|
||||
</td>
|
||||
<td v-if="activeColumns.done">
|
||||
<Done
|
||||
:is-done="t.done"
|
||||
variant="small"
|
||||
/>
|
||||
</td>
|
||||
<td v-if="activeColumns.title">
|
||||
<router-link :to="taskDetailRoutes[t.id]">
|
||||
{{ t.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td v-if="activeColumns.priority">
|
||||
<PriorityLabel
|
||||
:priority="t.priority"
|
||||
:done="t.done"
|
||||
:show-all="true"
|
||||
/>
|
||||
</td>
|
||||
<td v-if="activeColumns.labels">
|
||||
<Labels :labels="t.labels" />
|
||||
</td>
|
||||
<td v-if="activeColumns.assignees">
|
||||
<AssigneeList
|
||||
v-if="t.assignees.length > 0"
|
||||
:assignees="t.assignees"
|
||||
:avatar-size="28"
|
||||
class="ml-1"
|
||||
:inline="true"
|
||||
/>
|
||||
</td>
|
||||
<DateTableCell
|
||||
v-if="activeColumns.dueDate"
|
||||
:date="t.dueDate"
|
||||
/>
|
||||
<DateTableCell
|
||||
v-if="activeColumns.startDate"
|
||||
:date="t.startDate"
|
||||
/>
|
||||
<DateTableCell
|
||||
v-if="activeColumns.endDate"
|
||||
:date="t.endDate"
|
||||
/>
|
||||
<td v-if="activeColumns.percentDone">
|
||||
{{ t.percentDone * 100 }}%
|
||||
</td>
|
||||
<DateTableCell
|
||||
v-if="activeColumns.created"
|
||||
:date="t.created"
|
||||
/>
|
||||
<DateTableCell
|
||||
v-if="activeColumns.updated"
|
||||
:date="t.updated"
|
||||
/>
|
||||
<td v-if="activeColumns.createdBy">
|
||||
<User
|
||||
:avatar-size="27"
|
||||
:show-username="false"
|
||||
:user="t.createdBy"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:total-pages="totalPages"
|
||||
:current-page="currentPage"
|
||||
/>
|
||||
</card>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, type Ref} from 'vue'
|
||||
|
||||
import {useStorage} from '@vueuse/core'
|
||||
|
||||
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||
import Done from '@/components/misc/Done.vue'
|
||||
import User from '@/components/misc/user.vue'
|
||||
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
|
||||
import Labels from '@/components/tasks/partials/labels.vue'
|
||||
import DateTableCell from '@/components/tasks/partials/date-table-cell.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import Sort from '@/components/tasks/partials/sort.vue'
|
||||
import FilterPopup from '@/components/project/partials/filter-popup.vue'
|
||||
import Pagination from '@/components/misc/pagination.vue'
|
||||
import Popup from '@/components/misc/popup.vue'
|
||||
|
||||
import {useTaskList} from '@/composables/useTaskList'
|
||||
|
||||
import type {SortBy} from '@/composables/useTaskList'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
|
||||
|
||||
const {
|
||||
projectId,
|
||||
} = defineProps<{
|
||||
projectId: IProject['id'],
|
||||
}>()
|
||||
|
||||
const ACTIVE_COLUMNS_DEFAULT = {
|
||||
index: true,
|
||||
done: true,
|
||||
title: true,
|
||||
priority: false,
|
||||
labels: true,
|
||||
assignees: true,
|
||||
dueDate: true,
|
||||
startDate: false,
|
||||
endDate: false,
|
||||
percentDone: false,
|
||||
created: false,
|
||||
updated: false,
|
||||
createdBy: false,
|
||||
}
|
||||
|
||||
const SORT_BY_DEFAULT: SortBy = {
|
||||
index: 'desc',
|
||||
}
|
||||
|
||||
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
|
||||
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
|
||||
|
||||
const taskList = useTaskList(() => projectId, sortBy.value)
|
||||
|
||||
const {
|
||||
loading,
|
||||
params,
|
||||
totalPages,
|
||||
currentPage,
|
||||
sortByParam,
|
||||
} = taskList
|
||||
const tasks: Ref<ITask[]> = taskList.tasks
|
||||
|
||||
Object.assign(params.value, {
|
||||
filter_by: [],
|
||||
filter_value: [],
|
||||
filter_comparator: [],
|
||||
})
|
||||
|
||||
// FIXME: by doing this we can have multiple sort orders
|
||||
function sort(property: keyof SortBy) {
|
||||
const order = sortBy.value[property]
|
||||
if (typeof order === 'undefined' || order === 'none') {
|
||||
sortBy.value[property] = 'desc'
|
||||
} else if (order === 'desc') {
|
||||
sortBy.value[property] = 'asc'
|
||||
} else {
|
||||
delete sortBy.value[property]
|
||||
}
|
||||
sortByParam.value = sortBy.value
|
||||
}
|
||||
|
||||
// TODO: re-enable opening task detail in modal
|
||||
// const router = useRouter()
|
||||
const taskDetailRoutes = computed(() => Object.fromEntries(
|
||||
tasks.value.map(({id}) => ([
|
||||
id,
|
||||
{
|
||||
name: 'task.detail',
|
||||
params: {id},
|
||||
// state: { backdropView: router.currentRoute.value.fullPath },
|
||||
},
|
||||
])),
|
||||
))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.table {
|
||||
background: transparent;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.columns-filter {
|
||||
margin: 0;
|
||||
|
||||
&.is-open {
|
||||
margin: 2rem 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.link-share-view .card {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
128
frontend/src/views/project/helpers/useGanttFilters.ts
Normal file
128
frontend/src/views/project/helpers/useGanttFilters.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import type {Ref} from 'vue'
|
||||
import type {RouteLocationNormalized, RouteLocationRaw} from 'vue-router'
|
||||
|
||||
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
|
||||
import {parseDateProp} from '@/helpers/time/parseDateProp'
|
||||
import {parseBooleanProp} from '@/helpers/time/parseBooleanProp'
|
||||
import {useRouteFilters} from '@/composables/useRouteFilters'
|
||||
import {useGanttTaskList} from './useGanttTaskList'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {GetAllTasksParams} from '@/services/taskCollection'
|
||||
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {DateKebab} from '@/types/DateKebab'
|
||||
|
||||
// convenient internal filter object
|
||||
export interface GanttFilters {
|
||||
projectId: IProject['id']
|
||||
dateFrom: DateISO
|
||||
dateTo: DateISO
|
||||
showTasksWithoutDates: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_SHOW_TASKS_WITHOUT_DATES = false
|
||||
|
||||
const DEFAULT_DATEFROM_DAY_OFFSET = -15
|
||||
const DEFAULT_DATETO_DAY_OFFSET = +55
|
||||
|
||||
const now = new Date()
|
||||
|
||||
function getDefaultDateFrom() {
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATEFROM_DAY_OFFSET).toISOString()
|
||||
}
|
||||
|
||||
function getDefaultDateTo() {
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATETO_DAY_OFFSET).toISOString()
|
||||
}
|
||||
|
||||
// FIXME: use zod for this
|
||||
function ganttRouteToFilters(route: Partial<RouteLocationNormalized>): GanttFilters {
|
||||
const ganttRoute = route
|
||||
return {
|
||||
projectId: Number(ganttRoute.params?.projectId),
|
||||
dateFrom: parseDateProp(ganttRoute.query?.dateFrom as DateKebab) || getDefaultDateFrom(),
|
||||
dateTo: parseDateProp(ganttRoute.query?.dateTo as DateKebab) || getDefaultDateTo(),
|
||||
showTasksWithoutDates: parseBooleanProp(ganttRoute.query?.showTasksWithoutDates as string) || DEFAULT_SHOW_TASKS_WITHOUT_DATES,
|
||||
}
|
||||
}
|
||||
|
||||
function ganttGetDefaultFilters(route: Partial<RouteLocationNormalized>): GanttFilters {
|
||||
return ganttRouteToFilters({params: {projectId: route.params?.projectId as string}})
|
||||
}
|
||||
|
||||
// FIXME: use zod for this
|
||||
function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
|
||||
let query: Record<string, string> = {}
|
||||
if (
|
||||
filters.dateFrom !== getDefaultDateFrom() ||
|
||||
filters.dateTo !== getDefaultDateTo()
|
||||
) {
|
||||
query = {
|
||||
dateFrom: isoToKebabDate(filters.dateFrom),
|
||||
dateTo: isoToKebabDate(filters.dateTo),
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.showTasksWithoutDates) {
|
||||
query.showTasksWithoutDates = String(filters.showTasksWithoutDates)
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'project.gantt',
|
||||
params: {projectId: filters.projectId},
|
||||
query,
|
||||
}
|
||||
}
|
||||
|
||||
function ganttFiltersToApiParams(filters: GanttFilters): GetAllTasksParams {
|
||||
return {
|
||||
sort_by: ['start_date', 'done', 'id'],
|
||||
order_by: ['asc', 'asc', 'desc'],
|
||||
filter_by: ['start_date', 'start_date'],
|
||||
filter_comparator: ['greater_equals', 'less_equals'],
|
||||
filter_value: [isoToKebabDate(filters.dateFrom), isoToKebabDate(filters.dateTo)],
|
||||
filter_concat: 'and',
|
||||
filter_include_nulls: filters.showTasksWithoutDates,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseGanttFiltersReturn =
|
||||
ReturnType<typeof useRouteFilters<GanttFilters>> &
|
||||
ReturnType<typeof useGanttTaskList<GanttFilters>>
|
||||
|
||||
export function useGanttFilters(route: Ref<RouteLocationNormalized>): UseGanttFiltersReturn {
|
||||
const {
|
||||
filters,
|
||||
hasDefaultFilters,
|
||||
setDefaultFilters,
|
||||
} = useRouteFilters<GanttFilters>(
|
||||
route,
|
||||
ganttGetDefaultFilters,
|
||||
ganttRouteToFilters,
|
||||
ganttFiltersToRoute,
|
||||
['project.gantt'],
|
||||
)
|
||||
|
||||
const {
|
||||
tasks,
|
||||
loadTasks,
|
||||
|
||||
isLoading,
|
||||
addTask,
|
||||
updateTask,
|
||||
} = useGanttTaskList<GanttFilters>(filters, ganttFiltersToApiParams)
|
||||
|
||||
return {
|
||||
filters,
|
||||
hasDefaultFilters,
|
||||
setDefaultFilters,
|
||||
|
||||
tasks,
|
||||
loadTasks,
|
||||
|
||||
isLoading,
|
||||
addTask,
|
||||
updateTask,
|
||||
}
|
||||
}
|
102
frontend/src/views/project/helpers/useGanttTaskList.ts
Normal file
102
frontend/src/views/project/helpers/useGanttTaskList.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import {computed, ref, shallowReactive, watch, type Ref} from 'vue'
|
||||
import {klona} from 'klona/lite'
|
||||
|
||||
import type {Filters} from '@/composables/useRouteFilters'
|
||||
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||
|
||||
import TaskCollectionService, {type GetAllTasksParams} from '@/services/taskCollection'
|
||||
import TaskService from '@/services/task'
|
||||
|
||||
import TaskModel from '@/models/task'
|
||||
import {error, success} from '@/message'
|
||||
|
||||
// FIXME: unify with general `useTaskList`
|
||||
export function useGanttTaskList<F extends Filters>(
|
||||
filters: Ref<F>,
|
||||
filterToApiParams: (filters: F) => GetAllTasksParams,
|
||||
options: {
|
||||
loadAll?: boolean,
|
||||
} = {
|
||||
loadAll: true,
|
||||
}) {
|
||||
const taskCollectionService = shallowReactive(new TaskCollectionService())
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
|
||||
const isLoading = computed(() => taskCollectionService.loading)
|
||||
|
||||
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
|
||||
|
||||
async function fetchTasks(params: GetAllTasksParams, page = 1): Promise<ITask[]> {
|
||||
const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId}, params, page) as ITask[]
|
||||
if (options.loadAll && page < taskCollectionService.totalPages) {
|
||||
const nextTasks = await fetchTasks(params, page + 1)
|
||||
return tasks.concat(nextTasks)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and assign new tasks
|
||||
* Normally there is no need to trigger this manually
|
||||
*/
|
||||
async function loadTasks() {
|
||||
const params: GetAllTasksParams = filterToApiParams(filters.value)
|
||||
|
||||
const loadedTasks = await fetchTasks(params)
|
||||
tasks.value = new Map()
|
||||
loadedTasks.forEach(t => tasks.value.set(t.id, t))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tasks when filters change
|
||||
*/
|
||||
watch(
|
||||
filters,
|
||||
() => loadTasks(),
|
||||
{immediate: true, deep: true},
|
||||
)
|
||||
|
||||
async function addTask(task: Partial<ITask>) {
|
||||
const newTask = await taskService.create(new TaskModel({...task}))
|
||||
tasks.value.set(newTask.id, newTask)
|
||||
|
||||
return newTask
|
||||
}
|
||||
|
||||
async function updateTask(task: ITaskPartialWithId) {
|
||||
const oldTask = klona(tasks.value.get(task.id))
|
||||
|
||||
if (!oldTask) return
|
||||
|
||||
// we extend the task with potentially missing info
|
||||
const newTask: ITask = {
|
||||
...oldTask,
|
||||
...task,
|
||||
}
|
||||
|
||||
// set in expectation that server update works
|
||||
tasks.value.set(newTask.id, newTask)
|
||||
|
||||
try {
|
||||
const updatedTask = await taskService.update(newTask)
|
||||
// update the task with possible changes from server
|
||||
tasks.value.set(updatedTask.id, updatedTask)
|
||||
success('Saved')
|
||||
} catch (e) {
|
||||
error('Something went wrong saving the task')
|
||||
// roll back changes
|
||||
tasks.value.set(task.id, oldTask)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
tasks,
|
||||
|
||||
isLoading,
|
||||
loadTasks,
|
||||
|
||||
addTask,
|
||||
updateTask,
|
||||
}
|
||||
}
|
51
frontend/src/views/project/settings/archive.vue
Normal file
51
frontend/src/views/project/settings/archive.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<modal
|
||||
@close="$router.back()"
|
||||
@submit="archiveProject()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ project.isArchived ? $t('project.archive.unarchive') : $t('project.archive.archive') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>{{ project.isArchived ? $t('project.archive.unarchiveText') : $t('project.archive.archiveText') }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'ProjectSettingArchive'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {useRouter, useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const projectStore = useProjectStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const project = computed(() => projectStore.projects[route.params.projectId])
|
||||
useTitle(() => t('project.archive.title', {project: project.value.title}))
|
||||
|
||||
async function archiveProject() {
|
||||
try {
|
||||
const newProject = await projectStore.updateProject({
|
||||
...project.value,
|
||||
isArchived: !project.value.isArchived,
|
||||
})
|
||||
useBaseStore().setCurrentProject(newProject)
|
||||
success({message: t('project.archive.success')})
|
||||
} finally {
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
</script>
|
305
frontend/src/views/project/settings/background.vue
Normal file
305
frontend/src/views/project/settings/background.vue
Normal file
@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
v-if="uploadBackgroundEnabled || unsplashBackgroundEnabled"
|
||||
:title="$t('project.background.title')"
|
||||
:loading="backgroundService.loading"
|
||||
class="project-background-setting"
|
||||
:wide="true"
|
||||
>
|
||||
<div
|
||||
v-if="uploadBackgroundEnabled"
|
||||
class="mb-4"
|
||||
>
|
||||
<input
|
||||
ref="backgroundUploadInput"
|
||||
accept="image/*"
|
||||
class="is-hidden"
|
||||
type="file"
|
||||
@change="uploadBackground"
|
||||
>
|
||||
<x-button
|
||||
:loading="backgroundUploadService.loading"
|
||||
variant="primary"
|
||||
@click="backgroundUploadInput?.click()"
|
||||
>
|
||||
{{ $t('project.background.upload') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<template v-if="unsplashBackgroundEnabled">
|
||||
<input
|
||||
v-model="backgroundSearchTerm"
|
||||
:class="{'is-loading': backgroundService.loading}"
|
||||
class="input is-expanded"
|
||||
:placeholder="$t('project.background.searchPlaceholder')"
|
||||
type="text"
|
||||
@keyup="debounceNewBackgroundSearch()"
|
||||
>
|
||||
|
||||
<p class="unsplash-credit">
|
||||
<BaseButton
|
||||
class="unsplash-credit__link"
|
||||
href="https://unsplash.com"
|
||||
>
|
||||
{{ $t('project.background.poweredByUnsplash') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
|
||||
<ul class="image-search__result-list">
|
||||
<li
|
||||
v-for="im in backgroundSearchResult"
|
||||
:key="im.id"
|
||||
class="image-search__result-item"
|
||||
:style="{'background-image': `url(${backgroundBlurHashes[im.id]})`}"
|
||||
>
|
||||
<CustomTransition name="fade">
|
||||
<BaseButton
|
||||
v-if="backgroundThumbs[im.id]"
|
||||
class="image-search__image-button"
|
||||
@click="setBackground(im.id)"
|
||||
>
|
||||
<img
|
||||
class="image-search__image"
|
||||
:src="backgroundThumbs[im.id]"
|
||||
alt=""
|
||||
>
|
||||
</BaseButton>
|
||||
</CustomTransition>
|
||||
|
||||
<BaseButton
|
||||
:href="`https://unsplash.com/@${im.info.author}`"
|
||||
class="image-search__info"
|
||||
>
|
||||
{{ im.info.authorName }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
<x-button
|
||||
v-if="backgroundSearchResult.length > 0"
|
||||
:disabled="backgroundService.loading"
|
||||
class="is-load-more-button mt-4"
|
||||
:shadow="false"
|
||||
variant="secondary"
|
||||
@click="searchBackgrounds(currentPage + 1)"
|
||||
>
|
||||
{{ backgroundService.loading ? $t('misc.loading') : $t('project.background.loadMore') }}
|
||||
</x-button>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<x-button
|
||||
v-if="hasBackground"
|
||||
:shadow="false"
|
||||
variant="tertiary"
|
||||
class="is-danger"
|
||||
@click.prevent.stop="removeBackground"
|
||||
>
|
||||
{{ $t('project.background.remove') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
variant="secondary"
|
||||
@click.prevent.stop="$router.back()"
|
||||
>
|
||||
{{ $t('misc.close') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'ProjectSettingBackground' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
import BackgroundUnsplashService from '@/services/backgroundUnsplash'
|
||||
import BackgroundUploadService from '@/services/backgroundUpload'
|
||||
import ProjectService from '@/services/project'
|
||||
import type BackgroundImageModel from '@/models/backgroundImage'
|
||||
|
||||
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import {success} from '@/message'
|
||||
|
||||
const SEARCH_DEBOUNCE = 300
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const baseStore = useBaseStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useTitle(() => t('project.background.title'))
|
||||
|
||||
const backgroundService = shallowReactive(new BackgroundUnsplashService())
|
||||
const backgroundSearchTerm = ref('')
|
||||
const backgroundSearchResult = ref([])
|
||||
const backgroundThumbs = ref<Record<string, string>>({})
|
||||
const backgroundBlurHashes = ref<Record<string, string>>({})
|
||||
const currentPage = ref(1)
|
||||
|
||||
// We're using debounce to not search on every keypress but with a delay.
|
||||
const debounceNewBackgroundSearch = debounce(newBackgroundSearch, SEARCH_DEBOUNCE, {
|
||||
trailing: true,
|
||||
})
|
||||
|
||||
const backgroundUploadService = ref(new BackgroundUploadService())
|
||||
const projectService = ref(new ProjectService())
|
||||
const projectStore = useProjectStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const unsplashBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('unsplash'))
|
||||
const uploadBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('upload'))
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const hasBackground = computed(() => baseStore.background !== null)
|
||||
|
||||
// Show the default collection of backgrounds
|
||||
newBackgroundSearch()
|
||||
|
||||
function newBackgroundSearch() {
|
||||
if (!unsplashBackgroundEnabled.value) {
|
||||
return
|
||||
}
|
||||
// This is an extra method to reset a few things when searching to not break loading more photos.
|
||||
backgroundSearchResult.value = []
|
||||
backgroundThumbs.value = {}
|
||||
searchBackgrounds()
|
||||
}
|
||||
|
||||
async function searchBackgrounds(page = 1) {
|
||||
currentPage.value = page
|
||||
const result = await backgroundService.getAll({}, {s: backgroundSearchTerm.value, p: page})
|
||||
backgroundSearchResult.value = backgroundSearchResult.value.concat(result)
|
||||
result.forEach((background: BackgroundImageModel) => {
|
||||
getBlobFromBlurHash(background.blurHash)
|
||||
.then((b) => {
|
||||
backgroundBlurHashes.value[background.id] = window.URL.createObjectURL(b)
|
||||
})
|
||||
|
||||
backgroundService.thumb(background).then(b => {
|
||||
backgroundThumbs.value[background.id] = b
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
async function setBackground(backgroundId: string) {
|
||||
// Don't set a background if we're in the process of setting one
|
||||
if (backgroundService.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
const project = await backgroundService.update({
|
||||
id: backgroundId,
|
||||
projectId: route.params.projectId,
|
||||
})
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.success')})
|
||||
}
|
||||
|
||||
const backgroundUploadInput = ref<HTMLInputElement | null>(null)
|
||||
async function uploadBackground() {
|
||||
if (backgroundUploadInput.value?.files?.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const project = await backgroundUploadService.value.create(
|
||||
route.params.projectId,
|
||||
backgroundUploadInput.value?.files[0],
|
||||
)
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.success')})
|
||||
}
|
||||
|
||||
async function removeBackground() {
|
||||
const project = await projectService.value.removeBackground(currentProject.value)
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.removeSuccess')})
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.unsplash-credit {
|
||||
text-align: right;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.unsplash-credit__link {
|
||||
color: var(--grey-800);
|
||||
}
|
||||
|
||||
.image-search__result-list {
|
||||
--items-per-row: 1;
|
||||
margin: 1rem 0 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(var(--items-per-row), 1fr);
|
||||
|
||||
@media screen and (min-width: $mobile) {
|
||||
--items-per-row: 2;
|
||||
}
|
||||
@media screen and (min-width: $tablet) {
|
||||
--items-per-row: 4;
|
||||
}
|
||||
@media screen and (min-width: $tablet) {
|
||||
--items-per-row: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.image-search__result-item {
|
||||
margin-top: 0; // FIXME: removes padding from .content
|
||||
aspect-ratio: 16 / 10;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-search__image-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-search__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-search__info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: .25rem 0;
|
||||
opacity: 0;
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
font-size: .75rem;
|
||||
font-weight: bold;
|
||||
color: $white;
|
||||
transition: opacity $transition;
|
||||
}
|
||||
.image-search__result-item:hover .image-search__info {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.is-load-more-button {
|
||||
margin: 1rem auto 0 !important;
|
||||
display: block;
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
79
frontend/src/views/project/settings/delete.vue
Normal file
79
frontend/src/views/project/settings/delete.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<modal
|
||||
@close="$router.back()"
|
||||
@submit="deleteProject()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('project.delete.header') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('project.delete.text1') }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="totalTasks !== null"
|
||||
class="has-text-weight-bold"
|
||||
>
|
||||
{{
|
||||
totalTasks > 0 ? $t('project.delete.tasksToDelete', {count: totalTasks}) : $t('project.delete.noTasksToDelete')
|
||||
}}
|
||||
</p>
|
||||
<Loading
|
||||
v-else
|
||||
class="is-loading-small"
|
||||
variant="default"
|
||||
/>
|
||||
|
||||
<p>
|
||||
{{ $t('misc.cannotBeUndone') }}
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watchEffect} from 'vue'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {success} from '@/message'
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import Loading from '@/components/misc/loading.vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const projectStore = useProjectStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const totalTasks = ref<number | null>(null)
|
||||
|
||||
const project = computed(() => projectStore.projects[route.params.projectId])
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (!route.params.projectId) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskCollectionService = new TaskCollectionService()
|
||||
taskCollectionService.getAll({projectId: route.params.projectId}).then(() => {
|
||||
totalTasks.value = taskCollectionService.totalPages * taskCollectionService.resultCount
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
useTitle(() => t('project.delete.title', {project: project?.value?.title}))
|
||||
|
||||
async function deleteProject() {
|
||||
if (!project.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await projectStore.deleteProject(project.value)
|
||||
success({message: t('project.delete.success')})
|
||||
router.push({name: 'home'})
|
||||
}
|
||||
</script>
|
48
frontend/src/views/project/settings/duplicate.vue
Normal file
48
frontend/src/views/project/settings/duplicate.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('project.duplicate.title')"
|
||||
primary-icon="paste"
|
||||
:primary-label="$t('project.duplicate.label')"
|
||||
:loading="isLoading"
|
||||
@primary="duplicate"
|
||||
>
|
||||
<p>{{ $t('project.duplicate.text') }}</p>
|
||||
<ProjectSearch v-model="parentProject" />
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||
|
||||
import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useProject, useProjectStore} from '@/stores/projects'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => t('project.duplicate.title'))
|
||||
|
||||
const route = useRoute()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
|
||||
|
||||
const parentProject = ref<IProject | null>(null)
|
||||
watch(
|
||||
() => project.parentProjectId,
|
||||
parentProjectId => {
|
||||
parentProject.value = projectStore.projects[parentProjectId]
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
async function duplicate() {
|
||||
await duplicateProject(parentProject.value?.id ?? 0)
|
||||
success({message: t('project.duplicate.success')})
|
||||
}
|
||||
</script>
|
136
frontend/src/views/project/settings/edit.vue
Normal file
136
frontend/src/views/project/settings/edit.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('project.edit.header')"
|
||||
primary-icon=""
|
||||
:primary-label="$t('misc.save')"
|
||||
:tertiary="$t('misc.delete')"
|
||||
@primary="save"
|
||||
@tertiary="$router.push({ name: 'project.settings.delete', params: { id: projectId } })"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="title"
|
||||
>{{ $t('project.title') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="title"
|
||||
v-model="project.title"
|
||||
v-focus
|
||||
:class="{ 'disabled': isLoading}"
|
||||
:disabled="isLoading || undefined"
|
||||
class="input"
|
||||
:placeholder="$t('project.edit.titlePlaceholder')"
|
||||
type="text"
|
||||
@keyup.enter="save"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
v-tooltip="$t('project.edit.identifierTooltip')"
|
||||
class="label"
|
||||
for="identifier"
|
||||
>
|
||||
{{ $t('project.edit.identifier') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="identifier"
|
||||
v-model="project.identifier"
|
||||
v-focus
|
||||
:class="{ 'disabled': isLoading}"
|
||||
:disabled="isLoading || undefined"
|
||||
class="input"
|
||||
:placeholder="$t('project.edit.identifierPlaceholder')"
|
||||
type="text"
|
||||
@keyup.enter="save"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('project.parent') }}</label>
|
||||
<div class="control">
|
||||
<ProjectSearch v-model="parentProject" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="projectdescription"
|
||||
>{{ $t('project.edit.description') }}</label>
|
||||
<div class="control">
|
||||
<Editor
|
||||
id="projectdescription"
|
||||
v-model="project.description"
|
||||
:class="{ 'disabled': isLoading}"
|
||||
:disabled="isLoading"
|
||||
:placeholder="$t('project.edit.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('project.edit.color') }}</label>
|
||||
<div class="control">
|
||||
<ColorPicker v-model="project.hexColor" />
|
||||
</div>
|
||||
</div>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'ProjectSettingEdit'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {watch, ref, type PropType} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useProject} from '@/stores/projects'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: Number as PropType<IProject['id']>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const {project, save: saveProject, isLoading} = useProject(props.projectId)
|
||||
|
||||
const parentProject = ref<IProject | null>(null)
|
||||
watch(
|
||||
() => project.parentProjectId,
|
||||
projectId => {
|
||||
if (project.parentProjectId) {
|
||||
parentProject.value = projectStore.projects[project.parentProjectId]
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
useTitle(() => project?.title ? t('project.edit.title', {project: project.title}) : '')
|
||||
|
||||
async function save() {
|
||||
project.parentProjectId = parentProject.value?.id ?? project.parentProjectId
|
||||
await saveProject()
|
||||
await useBaseStore().handleSetCurrentProject({project})
|
||||
router.back()
|
||||
}
|
||||
</script>
|
78
frontend/src/views/project/settings/share.vue
Normal file
78
frontend/src/views/project/settings/share.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('project.share.header')"
|
||||
:has-primary-action="false"
|
||||
>
|
||||
<template v-if="project">
|
||||
<userTeam
|
||||
:id="project.id"
|
||||
:user-is-admin="userIsAdmin"
|
||||
share-type="user"
|
||||
type="project"
|
||||
/>
|
||||
<userTeam
|
||||
:id="project.id"
|
||||
:user-is-admin="userIsAdmin"
|
||||
share-type="team"
|
||||
type="project"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<LinkSharing
|
||||
v-if="linkSharingEnabled"
|
||||
:project-id="projectId"
|
||||
class="mt-4"
|
||||
/>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'ProjectSettingShare'}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed, watchEffect} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useTitle} from '@vueuse/core'
|
||||
|
||||
import ProjectService from '@/services/project'
|
||||
import ProjectModel from '@/models/project'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {RIGHTS} from '@/constants/rights'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import LinkSharing from '@/components/sharing/linkSharing.vue'
|
||||
import userTeam from '@/components/sharing/userTeam.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const project = ref<IProject>()
|
||||
const title = computed(() => project.value?.title
|
||||
? t('project.share.title', {project: project.value.title})
|
||||
: '',
|
||||
)
|
||||
useTitle(title)
|
||||
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const linkSharingEnabled = computed(() => configStore.linkSharingEnabled)
|
||||
const userIsAdmin = computed(() => project?.value?.maxRight === RIGHTS.ADMIN)
|
||||
|
||||
async function loadProject(projectId: number) {
|
||||
const projectService = new ProjectService()
|
||||
const newProject = await projectService.get(new ProjectModel({id: projectId}))
|
||||
await useBaseStore().handleSetCurrentProject({project: newProject})
|
||||
project.value = newProject
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.projectId !== undefined
|
||||
? parseInt(route.params.projectId as string)
|
||||
: undefined,
|
||||
)
|
||||
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
|
||||
</script>
|
280
frontend/src/views/project/settings/webhooks.vue
Normal file
280
frontend/src/views/project/settings/webhooks.vue
Normal file
@ -0,0 +1,280 @@
|
||||
<script lang="ts">
|
||||
export default {name: 'ProjectSettingWebhooks'}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed, watchEffect} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useTitle} from '@vueuse/core'
|
||||
|
||||
import ProjectService from '@/services/project'
|
||||
import ProjectModel from '@/models/project'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import type {IWebhook} from '@/modelTypes/IWebhook'
|
||||
import WebhookService from '@/services/webhook'
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
import User from '@/components/misc/user.vue'
|
||||
import WebhookModel from '@/models/webhook'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import {success} from '@/message'
|
||||
import {isValidHttpUrl} from '@/helpers/isValidHttpUrl'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const project = ref<IProject>()
|
||||
useTitle(t('project.webhooks.title'))
|
||||
|
||||
const showNewForm = ref(false)
|
||||
|
||||
async function loadProject(projectId: number) {
|
||||
const projectService = new ProjectService()
|
||||
const newProject = await projectService.get(new ProjectModel({id: projectId}))
|
||||
await useBaseStore().handleSetCurrentProject({project: newProject})
|
||||
project.value = newProject
|
||||
await loadWebhooks()
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.projectId !== undefined
|
||||
? parseInt(route.params.projectId as string)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
|
||||
|
||||
const webhooks = ref<IWebhook[]>()
|
||||
const webhookService = new WebhookService()
|
||||
const availableEvents = ref<string[]>()
|
||||
|
||||
async function loadWebhooks() {
|
||||
webhooks.value = await webhookService.getAll({projectId: project.value.id})
|
||||
availableEvents.value = await webhookService.getAvailableEvents()
|
||||
}
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const webhookIdToDelete = ref<number>()
|
||||
|
||||
async function deleteWebhook() {
|
||||
await webhookService.delete({
|
||||
id: webhookIdToDelete.value,
|
||||
projectId: project.value.id,
|
||||
})
|
||||
showDeleteModal.value = false
|
||||
success({message: t('project.webhooks.deleteSuccess')})
|
||||
await loadWebhooks()
|
||||
}
|
||||
|
||||
const newWebhook = ref(new WebhookModel())
|
||||
const newWebhookEvents = ref({})
|
||||
|
||||
async function create() {
|
||||
|
||||
validateTargetUrl()
|
||||
if (!webhookTargetUrlValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedEvents = getSelectedEventsArray()
|
||||
newWebhook.value.events = selectedEvents
|
||||
|
||||
validateSelectedEvents()
|
||||
if (!selectedEventsValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
newWebhook.value.projectId = project.value.id
|
||||
const created = await webhookService.create(newWebhook.value)
|
||||
webhooks.value.push(created)
|
||||
newWebhook.value = new WebhookModel()
|
||||
showNewForm.value = false
|
||||
}
|
||||
|
||||
const webhookTargetUrlValid = ref(true)
|
||||
|
||||
function validateTargetUrl() {
|
||||
webhookTargetUrlValid.value = isValidHttpUrl(newWebhook.value.targetUrl)
|
||||
}
|
||||
|
||||
const selectedEventsValid = ref(true)
|
||||
|
||||
function getSelectedEventsArray() {
|
||||
return Object.entries(newWebhookEvents.value)
|
||||
.filter(([_, use]) => use)
|
||||
.map(([event]) => event)
|
||||
}
|
||||
|
||||
function validateSelectedEvents() {
|
||||
const events = getSelectedEventsArray()
|
||||
if (events.length === 0) {
|
||||
selectedEventsValid.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('project.webhooks.title')"
|
||||
:has-primary-action="false"
|
||||
:wide="true"
|
||||
>
|
||||
<x-button
|
||||
v-if="!(webhooks?.length === 0 || showNewForm)"
|
||||
icon="plus"
|
||||
class="mb-4"
|
||||
@click="showNewForm = true"
|
||||
>
|
||||
{{ $t('project.webhooks.create') }}
|
||||
</x-button>
|
||||
|
||||
<div
|
||||
v-if="webhooks?.length === 0 || showNewForm"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="targetUrl"
|
||||
>
|
||||
{{ $t('project.webhooks.targetUrl') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="targetUrl"
|
||||
v-model="newWebhook.targetUrl"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="$t('project.webhooks.targetUrl')"
|
||||
@focusout="validateTargetUrl"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!webhookTargetUrlValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('project.webhooks.targetUrlInvalid') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="secret"
|
||||
>
|
||||
{{ $t('project.webhooks.secret') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="secret"
|
||||
v-model="newWebhook.secret"
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
<p class="help">
|
||||
{{ $t('project.webhooks.secretHint') }}
|
||||
<BaseButton href="https://vikunja.io/docs/webhooks/">
|
||||
{{ $t('project.webhooks.secretDocs') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="secret"
|
||||
>
|
||||
{{ $t('project.webhooks.events') }}
|
||||
</label>
|
||||
<p class="help">
|
||||
{{ $t('project.webhooks.eventsHint') }}
|
||||
</p>
|
||||
<div class="control">
|
||||
<Fancycheckbox
|
||||
v-for="event in availableEvents"
|
||||
:key="event"
|
||||
v-model="newWebhookEvents[event]"
|
||||
class="available-events-check"
|
||||
@update:modelValue="validateSelectedEvents"
|
||||
>
|
||||
{{ event }}
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
<p
|
||||
v-if="!selectedEventsValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('project.webhooks.mustSelectEvents') }}
|
||||
</p>
|
||||
</div>
|
||||
<x-button
|
||||
icon="plus"
|
||||
@click="create"
|
||||
>
|
||||
{{ $t('project.webhooks.create') }}
|
||||
</x-button>
|
||||
</div>
|
||||
|
||||
<table
|
||||
v-if="webhooks?.length > 0"
|
||||
class="table has-actions is-striped is-hoverable is-fullwidth"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('project.webhooks.targetUrl') }}</th>
|
||||
<th>{{ $t('project.webhooks.events') }}</th>
|
||||
<th>{{ $t('misc.created') }}</th>
|
||||
<th>{{ $t('misc.createdBy') }}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="w in webhooks"
|
||||
:key="w.id"
|
||||
>
|
||||
<td>{{ w.targetUrl }}</td>
|
||||
<td>{{ w.events.join(', ') }}</td>
|
||||
<td>{{ formatDateShort(w.created) }}</td>
|
||||
<td>
|
||||
<User
|
||||
:avatar-size="25"
|
||||
:user="w.createdBy"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td class="actions">
|
||||
<x-button
|
||||
class="is-danger"
|
||||
icon="trash-alt"
|
||||
@click="() => {showDeleteModal = true;webhookIdToDelete = w.id}"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteWebhook()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('project.webhooks.delete') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>{{ $t('project.webhooks.deleteText') }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.available-events-check {
|
||||
margin-right: .5rem;
|
||||
width: 12.5rem;
|
||||
}
|
||||
</style>
|
156
frontend/src/views/sharing/LinkSharingAuth.vue
Normal file
156
frontend/src/views/sharing/LinkSharingAuth.vue
Normal file
@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div>
|
||||
<Message v-if="loading">
|
||||
{{ $t('sharing.authenticating') }}
|
||||
</Message>
|
||||
<div
|
||||
v-if="authenticateWithPassword"
|
||||
class="box"
|
||||
>
|
||||
<p class="pb-2">
|
||||
{{ $t('sharing.passwordRequired') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input
|
||||
id="linkSharePassword"
|
||||
v-model="password"
|
||||
v-focus
|
||||
type="password"
|
||||
class="input"
|
||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||
@keyup.enter.prevent="authenticate()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
:loading="loading"
|
||||
@click="authenticate()"
|
||||
>
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
|
||||
<Message
|
||||
v-if="errorMessage !== ''"
|
||||
variant="danger"
|
||||
class="mt-4"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useTitle} from '@vueuse/core'
|
||||
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import {PROJECT_VIEWS, type ProjectView} from '@/types/ProjectView'
|
||||
import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(t('sharing.authenticating'))
|
||||
const {getLastVisitedRoute} = useRedirectToLastVisited()
|
||||
|
||||
function useAuth() {
|
||||
const baseStore = useBaseStore()
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const authenticateWithPassword = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const authLinkShare = computed(() => authStore.authLinkShare)
|
||||
|
||||
async function authenticate() {
|
||||
authenticateWithPassword.value = false
|
||||
errorMessage.value = ''
|
||||
|
||||
if (authLinkShare.value) {
|
||||
// FIXME: push to 'project.list' since authenticated?
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: no password
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const {project_id: projectId} = await authStore.linkShareAuth({
|
||||
hash: route.params.share,
|
||||
password: password.value,
|
||||
})
|
||||
const logoVisible = route.query.logoVisible
|
||||
? route.query.logoVisible === 'true'
|
||||
: true
|
||||
baseStore.setLogoVisible(logoVisible)
|
||||
|
||||
const view = route.query.view && Object.values(PROJECT_VIEWS).includes(route.query.view as ProjectView)
|
||||
? route.query.view
|
||||
: 'list'
|
||||
|
||||
const hash = LINK_SHARE_HASH_PREFIX + route.params.share
|
||||
|
||||
const last = getLastVisitedRoute()
|
||||
if (last) {
|
||||
return router.push({
|
||||
...last,
|
||||
hash,
|
||||
})
|
||||
}
|
||||
|
||||
return router.push({
|
||||
name: `project.${view}`,
|
||||
params: {projectId},
|
||||
hash,
|
||||
})
|
||||
} catch (e) {
|
||||
if (e?.response?.data?.code === 13001) {
|
||||
authenticateWithPassword.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Put this logic in a global errorMessage handler method which checks all auth codes
|
||||
let err = t('sharing.error')
|
||||
if (e?.response?.data?.message) {
|
||||
err = e.response.data.message
|
||||
}
|
||||
if (e?.response?.data?.code === 13002) {
|
||||
err = t('sharing.invalidPassword')
|
||||
}
|
||||
errorMessage.value = err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
authenticate()
|
||||
|
||||
return {
|
||||
loading,
|
||||
authenticateWithPassword,
|
||||
errorMessage,
|
||||
password,
|
||||
authenticate,
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
loading,
|
||||
authenticateWithPassword,
|
||||
errorMessage,
|
||||
password,
|
||||
authenticate,
|
||||
} = useAuth()
|
||||
</script>
|
246
frontend/src/views/tasks/ShowTasks.vue
Normal file
246
frontend/src/views/tasks/ShowTasks.vue
Normal file
@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div
|
||||
v-cy="'showTasks'"
|
||||
class="is-max-width-desktop has-text-left"
|
||||
>
|
||||
<h3 class="mb-2 title">
|
||||
{{ pageTitle }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="!showAll"
|
||||
class="show-tasks-options"
|
||||
>
|
||||
<DatepickerWithRange @update:modelValue="setDate">
|
||||
<template #trigger="{toggle}">
|
||||
<x-button
|
||||
variant="primary"
|
||||
:shadow="false"
|
||||
class="mb-2"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ $t('task.show.select') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</DatepickerWithRange>
|
||||
<Fancycheckbox
|
||||
:model-value="showNulls"
|
||||
class="mr-2"
|
||||
@update:modelValue="setShowNulls"
|
||||
>
|
||||
{{ $t('task.show.noDates') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox
|
||||
:model-value="showOverdue"
|
||||
@update:modelValue="setShowOverdue"
|
||||
>
|
||||
{{ $t('task.show.overdue') }}
|
||||
</Fancycheckbox>
|
||||
</p>
|
||||
<template v-if="!loading && (!tasks || tasks.length === 0) && showNothingToDo">
|
||||
<h3 class="has-text-centered mt-6">
|
||||
{{ $t('task.show.noTasks') }}
|
||||
</h3>
|
||||
<LlamaCool class="llama-cool" />
|
||||
</template>
|
||||
|
||||
<card
|
||||
v-if="hasTasks"
|
||||
:padding="false"
|
||||
class="has-overflow"
|
||||
:has-content="false"
|
||||
:loading="loading"
|
||||
>
|
||||
<div class="p-2">
|
||||
<SingleTaskInProject
|
||||
v-for="t in tasks"
|
||||
:key="t.id"
|
||||
:show-project="true"
|
||||
:the-task="t"
|
||||
@taskUpdated="updateTasks"
|
||||
/>
|
||||
</div>
|
||||
</card>
|
||||
<div
|
||||
v-else
|
||||
:class="{ 'is-loading': loading}"
|
||||
class="spinner"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watchEffect} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
import {setTitle} from '@/helpers/setTitle'
|
||||
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import SingleTaskInProject from '@/components/tasks/partials/singleTaskInProject.vue'
|
||||
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
|
||||
import {DATE_RANGES} from '@/components/date/dateRanges'
|
||||
import LlamaCool from '@/assets/llama-cool.svg?component'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
// Linting disabled because we explicitely enabled destructuring in vite's config, this will work.
|
||||
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||
const {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
showNulls = false,
|
||||
showOverdue = false,
|
||||
} = defineProps<{
|
||||
dateFrom?: Date | string,
|
||||
dateTo?: Date | string,
|
||||
showNulls?: boolean,
|
||||
showOverdue?: boolean,
|
||||
}>()
|
||||
const authStore = useAuthStore()
|
||||
const taskStore = useTaskStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const tasks = ref<ITask[]>([])
|
||||
const showNothingToDo = ref<boolean>(false)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
setTimeout(() => showNothingToDo.value = true, 100)
|
||||
|
||||
const showAll = computed(() => typeof dateFrom === 'undefined' || typeof dateTo === 'undefined')
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
// We need to define "key" because it is the first parameter in the array and we need the second
|
||||
const predefinedRange = Object.entries(DATE_RANGES)
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
.find(([, value]) => dateFrom === value[0] && dateTo === value[1])
|
||||
?.[0]
|
||||
if (typeof predefinedRange !== 'undefined') {
|
||||
return t(`input.datepickerRange.ranges.${predefinedRange}`)
|
||||
}
|
||||
|
||||
return showAll.value
|
||||
? t('task.show.titleCurrent')
|
||||
: t('task.show.fromuntil', {
|
||||
from: formatDate(dateFrom, 'PPP'),
|
||||
until: formatDate(dateTo, 'PPP'),
|
||||
})
|
||||
})
|
||||
const hasTasks = computed(() => tasks.value && tasks.value.length > 0)
|
||||
const userAuthenticated = computed(() => authStore.authenticated)
|
||||
const loading = computed(() => taskStore.isLoading)
|
||||
|
||||
interface dateStrings {
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
}
|
||||
|
||||
function setDate(dates: dateStrings) {
|
||||
router.push({
|
||||
name: route.name as string,
|
||||
query: {
|
||||
from: dates.dateFrom ?? dateFrom,
|
||||
to: dates.dateTo ?? dateTo,
|
||||
showOverdue: showOverdue ? 'true' : 'false',
|
||||
showNulls: showNulls ? 'true' : 'false',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function setShowOverdue(show: boolean) {
|
||||
router.push({
|
||||
name: route.name as string,
|
||||
query: {
|
||||
...route.query,
|
||||
showOverdue: show ? 'true' : 'false',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function setShowNulls(show: boolean) {
|
||||
router.push({
|
||||
name: route.name as string,
|
||||
query: {
|
||||
...route.query,
|
||||
showNulls: show ? 'true' : 'false',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function loadPendingTasks(from: string, to: string) {
|
||||
// FIXME: HACK! This should never happen.
|
||||
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
|
||||
// Since this component is mounted as the home page before unauthenticated users get redirected
|
||||
// to the login page, they will almost always see the error message.
|
||||
if (!userAuthenticated.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const params = {
|
||||
sortBy: ['due_date', 'id'],
|
||||
orderBy: ['asc', 'desc'],
|
||||
filterBy: ['done'],
|
||||
filterValue: ['false'],
|
||||
filterComparator: ['equals'],
|
||||
filterConcat: 'and',
|
||||
filterIncludeNulls: showNulls,
|
||||
}
|
||||
|
||||
if (!showAll.value) {
|
||||
params.filterBy.push('due_date')
|
||||
params.filterValue.push(to)
|
||||
params.filterComparator.push('less')
|
||||
|
||||
// NOTE: Ideally we could also show tasks with a start or end date in the specified range, but the api
|
||||
// is not capable (yet) of combining multiple filters with 'and' and 'or'.
|
||||
|
||||
if (!showOverdue) {
|
||||
params.filterBy.push('due_date')
|
||||
params.filterValue.push(from)
|
||||
params.filterComparator.push('greater')
|
||||
}
|
||||
}
|
||||
|
||||
if (showAll.value && authStore.settings.frontendSettings.filterIdUsedOnOverview && typeof projectStore.projects[authStore.settings.frontendSettings.filterIdUsedOnOverview] !== 'undefined') {
|
||||
tasks.value = await taskStore.loadTasks(params, authStore.settings.frontendSettings.filterIdUsedOnOverview)
|
||||
return
|
||||
}
|
||||
|
||||
tasks.value = await taskStore.loadTasks(params)
|
||||
}
|
||||
|
||||
// FIXME: this modification should happen in the store
|
||||
function updateTasks(updatedTask: ITask) {
|
||||
for (const t in tasks.value) {
|
||||
if (tasks.value[t].id === updatedTask.id) {
|
||||
tasks.value[t] = updatedTask
|
||||
// Move the task to the end of the done tasks if it is now done
|
||||
if (updatedTask.done) {
|
||||
tasks.value.splice(t, 1)
|
||||
tasks.value.push(updatedTask)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => loadPendingTasks(dateFrom as string, dateTo as string))
|
||||
watchEffect(() => setTitle(pageTitle.value))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.show-tasks-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.llama-cool {
|
||||
margin: 3rem auto 0;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
1146
frontend/src/views/tasks/TaskDetailView.vue
Normal file
1146
frontend/src/views/tasks/TaskDetailView.vue
Normal file
File diff suppressed because it is too large
Load Diff
396
frontend/src/views/teams/EditTeam.vue
Normal file
396
frontend/src/views/teams/EditTeam.vue
Normal file
@ -0,0 +1,396 @@
|
||||
<template>
|
||||
<div
|
||||
class="loader-container is-max-width-desktop"
|
||||
:class="{ 'is-loading': teamService.loading }"
|
||||
>
|
||||
<card
|
||||
v-if="userIsAdmin"
|
||||
class="is-fullwidth"
|
||||
:title="title"
|
||||
>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="teamtext"
|
||||
>{{ $t('team.attributes.name') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="teamtext"
|
||||
v-model="team.name"
|
||||
v-focus
|
||||
:class="{ disabled: teamMemberService.loading }"
|
||||
:disabled="teamMemberService.loading || undefined"
|
||||
class="input"
|
||||
:placeholder="$t('team.attributes.namePlaceholder')"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="showErrorTeamnameRequired && team.name === ''"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('team.attributes.nameRequired') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="teamdescription"
|
||||
>{{ $t('team.attributes.description') }}</label>
|
||||
<div class="control">
|
||||
<Editor
|
||||
id="teamdescription"
|
||||
v-model="team.description"
|
||||
:class="{ disabled: teamService.loading }"
|
||||
:disabled="teamService.loading"
|
||||
:placeholder="$t('team.attributes.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="field has-addons mt-4">
|
||||
<div class="control is-fullwidth">
|
||||
<x-button
|
||||
:loading="teamService.loading"
|
||||
class="is-fullwidth"
|
||||
@click="save()"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
:loading="teamService.loading"
|
||||
class="is-danger"
|
||||
icon="trash-alt"
|
||||
@click="showDeleteModal = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</card>
|
||||
|
||||
<card
|
||||
class="is-fullwidth has-overflow"
|
||||
:title="$t('team.edit.members')"
|
||||
:padding="false"
|
||||
>
|
||||
<div
|
||||
v-if="userIsAdmin"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<Multiselect
|
||||
v-model="newMember"
|
||||
:loading="userService.loading"
|
||||
:placeholder="$t('team.edit.search')"
|
||||
:search-results="foundUsers"
|
||||
label="username"
|
||||
@search="findUser"
|
||||
>
|
||||
<template #searchResult="{option: user}">
|
||||
<User
|
||||
:avatar-size="24"
|
||||
:user="user"
|
||||
class="m-0"
|
||||
/>
|
||||
</template>
|
||||
</Multiselect>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
icon="plus"
|
||||
@click="addUser"
|
||||
>
|
||||
{{ $t('team.edit.addUser') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="showMustSelectUserError"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('team.edit.mustSelectUser') }}
|
||||
</p>
|
||||
</div>
|
||||
<table class="table has-actions is-striped is-hoverable is-fullwidth">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="m in team?.members"
|
||||
:key="m.id"
|
||||
>
|
||||
<td>
|
||||
<User
|
||||
:avatar-size="24"
|
||||
:user="m"
|
||||
class="m-0"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="m.id === userInfo.id">
|
||||
<b class="is-success">You</b>
|
||||
</template>
|
||||
</td>
|
||||
<td class="type">
|
||||
<template v-if="m.admin">
|
||||
<span class="icon is-small">
|
||||
<icon icon="lock" />
|
||||
</span>
|
||||
{{ $t('team.attributes.admin') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="icon is-small">
|
||||
<icon icon="user" />
|
||||
</span>
|
||||
{{ $t('team.attributes.member') }}
|
||||
</template>
|
||||
</td>
|
||||
<td
|
||||
v-if="userIsAdmin"
|
||||
class="actions"
|
||||
>
|
||||
<x-button
|
||||
v-if="m.id !== userInfo.id"
|
||||
:loading="teamMemberService.loading"
|
||||
class="mr-2"
|
||||
@click="() => toggleUserType(m)"
|
||||
>
|
||||
{{ m.admin ? $t('team.edit.makeMember') : $t('team.edit.makeAdmin') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
v-if="m.id !== userInfo.id"
|
||||
:loading="teamMemberService.loading"
|
||||
class="is-danger"
|
||||
icon="trash-alt"
|
||||
@click="() => {memberToDelete = m; showUserDeleteModal = true}"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</card>
|
||||
|
||||
<x-button
|
||||
class="is-fullwidth is-danger"
|
||||
@click="showLeaveModal = true"
|
||||
>
|
||||
{{ $t('team.edit.leave.title') }}
|
||||
</x-button>
|
||||
|
||||
<!-- Leave team modal -->
|
||||
<modal
|
||||
v-if="showLeaveModal"
|
||||
@close="showLeaveModal = false"
|
||||
@submit="leave()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('team.edit.leave.title') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('team.edit.leave.text1') }}<br>
|
||||
{{ $t('team.edit.leave.text2') }}
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
|
||||
<!-- Team delete modal -->
|
||||
<modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteTeam()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('team.edit.delete.header') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('team.edit.delete.text1') }}<br>
|
||||
{{ $t('team.edit.delete.text2') }}
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
|
||||
<!-- User delete modal -->
|
||||
<modal
|
||||
:enabled="showUserDeleteModal"
|
||||
@close="showUserDeleteModal = false"
|
||||
@submit="deleteMember()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('team.edit.deleteUser.header') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('team.edit.deleteUser.text1') }}<br>
|
||||
{{ $t('team.edit.deleteUser.text2') }}
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
import User from '@/components/misc/user.vue'
|
||||
|
||||
import TeamService from '@/services/team'
|
||||
import TeamMemberService from '@/services/teamMember'
|
||||
import UserService from '@/services/user'
|
||||
|
||||
import {RIGHTS as Rights} from '@/constants/rights'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
import type {ITeam} from '@/modelTypes/ITeam'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import type {ITeamMember} from '@/modelTypes/ITeamMember'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const userIsAdmin = computed(() => {
|
||||
return (
|
||||
team.value &&
|
||||
team.value.maxRight &&
|
||||
team.value.maxRight > Rights.READ
|
||||
)
|
||||
})
|
||||
const userInfo = computed(() => authStore.info)
|
||||
|
||||
const teamService = ref<TeamService>(new TeamService())
|
||||
const teamMemberService = ref<TeamMemberService>(new TeamMemberService())
|
||||
const userService = ref<UserService>(new UserService())
|
||||
|
||||
const team = ref<ITeam>()
|
||||
const teamId = computed(() => Number(route.params.id))
|
||||
const memberToDelete = ref<ITeamMember>()
|
||||
const newMember = ref<IUser>()
|
||||
const foundUsers = ref<IUser[]>()
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const showUserDeleteModal = ref(false)
|
||||
const showLeaveModal = ref(false)
|
||||
const showErrorTeamnameRequired = ref(false)
|
||||
const showMustSelectUserError = ref(false)
|
||||
|
||||
const title = ref('')
|
||||
|
||||
loadTeam()
|
||||
|
||||
async function loadTeam() {
|
||||
team.value = await teamService.value.get({id: teamId.value})
|
||||
title.value = t('team.edit.title', {team: team.value?.name})
|
||||
useTitle(() => title.value)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (team.value?.name === '') {
|
||||
showErrorTeamnameRequired.value = true
|
||||
return
|
||||
}
|
||||
showErrorTeamnameRequired.value = false
|
||||
|
||||
team.value = await teamService.value.update(team.value)
|
||||
success({message: t('team.edit.success')})
|
||||
}
|
||||
|
||||
async function deleteTeam() {
|
||||
await teamService.value.delete(team.value)
|
||||
success({message: t('team.edit.delete.success')})
|
||||
router.push({name: 'teams.index'})
|
||||
}
|
||||
|
||||
async function deleteMember() {
|
||||
try {
|
||||
await teamMemberService.value.delete({
|
||||
teamId: teamId.value,
|
||||
username: memberToDelete.value.username,
|
||||
})
|
||||
success({message: t('team.edit.deleteUser.success')})
|
||||
await loadTeam()
|
||||
} finally {
|
||||
showUserDeleteModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
showMustSelectUserError.value = false
|
||||
if(!newMember.value) {
|
||||
showMustSelectUserError.value = true
|
||||
return
|
||||
}
|
||||
await teamMemberService.value.create({
|
||||
teamId: teamId.value,
|
||||
username: newMember.value.username,
|
||||
})
|
||||
newMember.value = null
|
||||
await loadTeam()
|
||||
success({message: t('team.edit.userAddedSuccess')})
|
||||
}
|
||||
|
||||
async function toggleUserType(member: ITeamMember) {
|
||||
// FIXME: direct manipulation
|
||||
member.admin = !member.admin
|
||||
member.teamId = teamId.value
|
||||
const r = await teamMemberService.value.update(member)
|
||||
for (const tm in team.value.members) {
|
||||
if (team.value.members[tm].id === member.id) {
|
||||
team.value.members[tm].admin = r.admin
|
||||
break
|
||||
}
|
||||
}
|
||||
success({
|
||||
message: member.admin ?
|
||||
t('team.edit.madeAdmin') :
|
||||
t('team.edit.madeMember'),
|
||||
})
|
||||
}
|
||||
|
||||
async function findUser(query: string) {
|
||||
if (query === '') {
|
||||
foundUsers.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const users = await userService.value.getAll({}, {s: query})
|
||||
foundUsers.value = users.filter((u: IUser) => u.id !== userInfo.value.id)
|
||||
}
|
||||
|
||||
async function leave() {
|
||||
try {
|
||||
await teamMemberService.value.delete({
|
||||
teamId: teamId.value,
|
||||
username: userInfo.value.username,
|
||||
})
|
||||
success({message: t('team.edit.leave.success')})
|
||||
await router.push({name: 'home'})
|
||||
} finally {
|
||||
showUserDeleteModal.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card.is-fullwidth {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
84
frontend/src/views/teams/ListTeams.vue
Normal file
84
frontend/src/views/teams/ListTeams.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div
|
||||
class="content loader-container is-max-width-desktop"
|
||||
:class="{ 'is-loading': teamService.loading}"
|
||||
>
|
||||
<x-button
|
||||
:to="{name:'teams.create'}"
|
||||
class="is-pulled-right"
|
||||
icon="plus"
|
||||
>
|
||||
{{ $t('team.create.title') }}
|
||||
</x-button>
|
||||
|
||||
<h1>{{ $t('team.title') }}</h1>
|
||||
<ul
|
||||
v-if="teams.length > 0"
|
||||
class="teams box"
|
||||
>
|
||||
<li
|
||||
v-for="team in teams"
|
||||
:key="team.id"
|
||||
>
|
||||
<router-link :to="{name: 'teams.edit', params: {id: team.id}}">
|
||||
{{ team.name }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else-if="!teamService.loading"
|
||||
class="has-text-centered has-text-grey is-italic"
|
||||
>
|
||||
{{ $t('team.noTeams') }}
|
||||
<router-link :to="{name: 'teams.create'}">
|
||||
{{ $t('team.create.title') }}.
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TeamService from '@/services/team'
|
||||
import { useTitle } from '@/composables/useTitle'
|
||||
|
||||
const { t } = useI18n({useScope: 'global'})
|
||||
useTitle(() => t('team.title'))
|
||||
|
||||
const teams = ref([])
|
||||
const teamService = shallowReactive(new TeamService())
|
||||
teamService.getAll().then((result) => {
|
||||
teams.value = result
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
ul.teams {
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
overflow: hidden;
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid $border;
|
||||
|
||||
a {
|
||||
color: var(--text);
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: background-color $transition;
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
</style>
|
77
frontend/src/views/teams/NewTeam.vue
Normal file
77
frontend/src/views/teams/NewTeam.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="title"
|
||||
:primary-disabled="team.name === ''"
|
||||
@create="newTeam()"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="teamName"
|
||||
>{{ $t('team.attributes.name') }}</label>
|
||||
<div
|
||||
class="control is-expanded"
|
||||
:class="{ 'is-loading': teamService.loading }"
|
||||
>
|
||||
<input
|
||||
id="teamName"
|
||||
v-model="team.name"
|
||||
v-focus
|
||||
:class="{ 'disabled': teamService.loading }"
|
||||
class="input"
|
||||
:placeholder="$t('team.attributes.namePlaceholder')"
|
||||
type="text"
|
||||
@keyup.enter="newTeam"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="showError && team.name === ''"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('team.attributes.nameRequired') }}
|
||||
</p>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'NewTeam' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {reactive, ref, shallowReactive, computed} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import TeamModel from '@/models/team'
|
||||
import TeamService from '@/services/team'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {success} from '@/message'
|
||||
|
||||
const {t} = useI18n()
|
||||
const title = computed(() => t('team.create.title'))
|
||||
useTitle(title)
|
||||
const router = useRouter()
|
||||
|
||||
const teamService = shallowReactive(new TeamService())
|
||||
const team = reactive(new TeamModel())
|
||||
const showError = ref(false)
|
||||
|
||||
async function newTeam() {
|
||||
if (team.name === '') {
|
||||
showError.value = true
|
||||
return
|
||||
}
|
||||
showError.value = false
|
||||
|
||||
const response = await teamService.create(team)
|
||||
router.push({
|
||||
name: 'teams.edit',
|
||||
params: { id: response.id },
|
||||
})
|
||||
success({message: t('team.create.success') })
|
||||
}
|
||||
</script>
|
67
frontend/src/views/user/DataExportDownload.vue
Normal file
67
frontend/src/views/user/DataExportDownload.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<h1>{{ $t('user.export.downloadTitle') }}</h1>
|
||||
<template v-if="isLocalUser">
|
||||
<p>{{ $t('user.export.descriptionPasswordRequired') }}</p>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPasswordDataExport"
|
||||
>
|
||||
{{ $t('user.settings.currentPassword') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPasswordDataExport"
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
class="input"
|
||||
:class="{'is-danger': errPasswordRequired}"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup="() => errPasswordRequired = password === ''"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="errPasswordRequired"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.deletion.passwordRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<x-button
|
||||
v-focus
|
||||
:loading="dataExportService.loading"
|
||||
class="mt-4"
|
||||
@click="download()"
|
||||
>
|
||||
{{ $t('misc.download') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, reactive} from 'vue'
|
||||
import DataExportService from '@/services/dataExport'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const dataExportService = reactive(new DataExportService())
|
||||
const password = ref('')
|
||||
const errPasswordRequired = ref(false)
|
||||
const passwordInput = ref(null)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
|
||||
function download() {
|
||||
if (password.value === '' && isLocalUser.value) {
|
||||
errPasswordRequired.value = true
|
||||
passwordInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
dataExportService.download(password.value)
|
||||
}
|
||||
</script>
|
261
frontend/src/views/user/Login.vue
Normal file
261
frontend/src/views/user/Login.vue
Normal file
@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div>
|
||||
<Message
|
||||
v-if="confirmedEmailSuccess"
|
||||
variant="success"
|
||||
text-align="center"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ $t('user.auth.confirmEmailSuccess') }}
|
||||
</Message>
|
||||
<Message
|
||||
v-if="errorMessage"
|
||||
variant="danger"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</Message>
|
||||
<form
|
||||
v-if="localAuthEnabled"
|
||||
id="loginform"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="username"
|
||||
>{{ $t('user.auth.usernameEmail') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="username"
|
||||
ref="usernameRef"
|
||||
v-focus
|
||||
class="input"
|
||||
name="username"
|
||||
:placeholder="$t('user.auth.usernamePlaceholder')"
|
||||
required
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
tabindex="1"
|
||||
@keyup.enter="submit"
|
||||
@focusout="validateUsernameField()"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!usernameValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.auth.usernameRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="label-with-link">
|
||||
<label
|
||||
class="label"
|
||||
for="password"
|
||||
>{{ $t('user.auth.password') }}</label>
|
||||
<router-link
|
||||
:to="{ name: 'user.password-reset.request' }"
|
||||
class="reset-password-link"
|
||||
tabindex="6"
|
||||
>
|
||||
{{ $t('user.auth.forgotPassword') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<Password
|
||||
v-model="password"
|
||||
tabindex="2"
|
||||
:validate-initially="validatePasswordInitially"
|
||||
@submit="submit"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="needsTotpPasscode"
|
||||
class="field"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
for="totpPasscode"
|
||||
>{{ $t('user.auth.totpTitle') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="totpPasscode"
|
||||
ref="totpPasscode"
|
||||
v-focus
|
||||
autocomplete="one-time-code"
|
||||
class="input"
|
||||
:placeholder="$t('user.auth.totpPlaceholder')"
|
||||
required
|
||||
type="text"
|
||||
tabindex="3"
|
||||
inputmode="numeric"
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<input
|
||||
v-model="rememberMe"
|
||||
type="checkbox"
|
||||
class="mr-1"
|
||||
>
|
||||
{{ $t('user.auth.remember') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
:loading="isLoading"
|
||||
tabindex="4"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
<p
|
||||
v-if="registrationEnabled"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ $t('user.auth.noAccountYet') }}
|
||||
<router-link
|
||||
:to="{ name: 'user.register' }"
|
||||
type="secondary"
|
||||
tabindex="5"
|
||||
>
|
||||
{{ $t('user.auth.createAccount') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="hasOpenIdProviders"
|
||||
class="mt-4"
|
||||
>
|
||||
<x-button
|
||||
v-for="(p, k) in openidConnect.providers"
|
||||
:key="k"
|
||||
variant="secondary"
|
||||
class="is-fullwidth mt-2"
|
||||
@click="redirectToProvider(p)"
|
||||
>
|
||||
{{ $t('user.auth.loginWith', {provider: p.name}) }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeMount, ref} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import Password from '@/components/input/password.vue'
|
||||
|
||||
import {getErrorText} from '@/message'
|
||||
import {redirectToProvider} from '@/helpers/redirectToProvider'
|
||||
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => t('user.auth.login'))
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const {redirectIfSaved} = useRedirectToLastVisited()
|
||||
|
||||
const registrationEnabled = computed(() => configStore.registrationEnabled)
|
||||
const localAuthEnabled = computed(() => configStore.auth.local.enabled)
|
||||
|
||||
const openidConnect = computed(() => configStore.auth.openidConnect)
|
||||
const hasOpenIdProviders = computed(() => openidConnect.value.enabled && openidConnect.value.providers?.length > 0)
|
||||
|
||||
const isLoading = computed(() => authStore.isLoading)
|
||||
|
||||
const confirmedEmailSuccess = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const password = ref('')
|
||||
const validatePasswordInitially = ref(false)
|
||||
const rememberMe = ref(false)
|
||||
|
||||
const authenticated = computed(() => authStore.authenticated)
|
||||
|
||||
onBeforeMount(() => {
|
||||
authStore.verifyEmail().then((confirmed) => {
|
||||
confirmedEmailSuccess.value = confirmed
|
||||
}).catch((e: Error) => {
|
||||
errorMessage.value = e.message
|
||||
})
|
||||
|
||||
// Check if the user is already logged in, if so, redirect them to the homepage
|
||||
if (authenticated.value) {
|
||||
redirectIfSaved()
|
||||
}
|
||||
})
|
||||
|
||||
const usernameValid = ref(true)
|
||||
const usernameRef = ref<HTMLInputElement | null>(null)
|
||||
const validateUsernameField = useDebounceFn(() => {
|
||||
usernameValid.value = usernameRef.value?.value !== ''
|
||||
}, 100)
|
||||
|
||||
const needsTotpPasscode = computed(() => authStore.needsTotpPasscode)
|
||||
const totpPasscode = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function submit() {
|
||||
errorMessage.value = ''
|
||||
// Some browsers prevent Vue bindings from working with autofilled values.
|
||||
// To work around this, we're manually getting the values here instead of relying on vue bindings.
|
||||
// For more info, see https://kolaente.dev/vikunja/frontend/issues/78
|
||||
const credentials = {
|
||||
username: usernameRef.value?.value,
|
||||
password: password.value,
|
||||
longToken: rememberMe.value,
|
||||
}
|
||||
|
||||
if (credentials.username === '' || credentials.password === '') {
|
||||
// Trigger the validation error messages
|
||||
validateUsernameField()
|
||||
validatePasswordInitially.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (needsTotpPasscode.value) {
|
||||
credentials.totpPasscode = totpPasscode.value?.value
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.login(credentials)
|
||||
authStore.setNeedsTotpPasscode(false)
|
||||
} catch (e) {
|
||||
if (e.response?.data.code === 1017 && !credentials.totpPasscode) {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = getErrorText(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.button {
|
||||
margin: 0 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.reset-password-link {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.label-with-link {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: .5rem;
|
||||
|
||||
.label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
93
frontend/src/views/user/OpenIdAuth.vue
Normal file
93
frontend/src/views/user/OpenIdAuth.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div>
|
||||
<Message
|
||||
v-if="errorMessage"
|
||||
variant="danger"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</Message>
|
||||
<Message
|
||||
v-if="errorMessageFromQuery"
|
||||
variant="danger"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ errorMessageFromQuery }}
|
||||
</Message>
|
||||
<Message v-if="loading">
|
||||
{{ $t('user.auth.authenticating') }}
|
||||
</Message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'Auth' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {getErrorText} from '@/message'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const route = useRoute()
|
||||
const {redirectIfSaved} = useRedirectToLastVisited()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = computed(() => authStore.isLoading)
|
||||
const errorMessage = ref('')
|
||||
const errorMessageFromQuery = computed(() => route.query.error)
|
||||
|
||||
async function authenticateWithCode() {
|
||||
// This component gets mounted twice: The first time when the actual auth request hits the frontend,
|
||||
// the second time after that auth request succeeded and the outer component "content-no-auth" isn't used
|
||||
// but instead the "content-auth" component is used. Because this component is just a route and thus
|
||||
// gets mounted as part of a <router-view/> which both the content-auth and content-no-auth components have,
|
||||
// this re-mounts the component, even if the user is already authenticated.
|
||||
// To make sure we only try to authenticate the user once, we set this "authenticating" lock in localStorage
|
||||
// which ensures only one auth request is done at a time. We don't simply check if the user is already
|
||||
// authenticated to not prevent the whole authentication if some user is already logged in.
|
||||
if (localStorage.getItem('authenticating')) {
|
||||
return
|
||||
}
|
||||
localStorage.setItem('authenticating', 'true')
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
if (typeof route.query.error !== 'undefined') {
|
||||
localStorage.removeItem('authenticating')
|
||||
errorMessage.value = typeof route.query.message !== 'undefined'
|
||||
? route.query.message as string
|
||||
: t('user.auth.openIdGeneralError')
|
||||
return
|
||||
}
|
||||
|
||||
const state = localStorage.getItem('state')
|
||||
if (typeof route.query.state === 'undefined' || route.query.state !== state) {
|
||||
localStorage.removeItem('authenticating')
|
||||
errorMessage.value = t('user.auth.openIdStateError')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.openIdAuth({
|
||||
provider: route.params.provider,
|
||||
code: route.query.code,
|
||||
})
|
||||
redirectIfSaved()
|
||||
} catch(e) {
|
||||
errorMessage.value = getErrorText(e)
|
||||
} finally {
|
||||
localStorage.removeItem('authenticating')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => authenticateWithCode())
|
||||
</script>
|
91
frontend/src/views/user/PasswordReset.vue
Normal file
91
frontend/src/views/user/PasswordReset.vue
Normal file
@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div>
|
||||
<Message
|
||||
v-if="errorMsg"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ errorMsg }}
|
||||
</Message>
|
||||
<div
|
||||
v-if="successMessage"
|
||||
class="has-text-centered mb-4"
|
||||
>
|
||||
<Message variant="success">
|
||||
{{ successMessage }}
|
||||
</Message>
|
||||
<x-button
|
||||
:to="{ name: 'user.login' }"
|
||||
class="mt-4"
|
||||
>
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<form
|
||||
v-if="!successMessage"
|
||||
id="form"
|
||||
@submit.prevent="resetPassword"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="password"
|
||||
>{{ $t('user.auth.password') }}</label>
|
||||
<Password
|
||||
@submit="resetPassword"
|
||||
@update:modelValue="v => credentials.password = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<x-button
|
||||
:loading="passwordResetService.loading"
|
||||
@click="resetPassword"
|
||||
>
|
||||
{{ $t('user.auth.resetPassword') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive} from 'vue'
|
||||
|
||||
import PasswordResetModel from '@/models/passwordReset'
|
||||
import PasswordResetService from '@/services/passwordReset'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import Password from '@/components/input/password.vue'
|
||||
|
||||
const credentials = reactive({
|
||||
password: '',
|
||||
})
|
||||
|
||||
const passwordResetService = reactive(new PasswordResetService())
|
||||
const errorMsg = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
async function resetPassword() {
|
||||
errorMsg.value = ''
|
||||
|
||||
if(credentials.password === '') {
|
||||
return
|
||||
}
|
||||
|
||||
const passwordReset = new PasswordResetModel({newPassword: credentials.password})
|
||||
try {
|
||||
const {message} = await passwordResetService.resetPassword(passwordReset)
|
||||
successMessage.value = message
|
||||
localStorage.removeItem('passwordResetToken')
|
||||
} catch (e) {
|
||||
errorMsg.value = e.response.data.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button {
|
||||
margin: 0 0.4rem 0 0;
|
||||
}
|
||||
</style>
|
176
frontend/src/views/user/Register.vue
Normal file
176
frontend/src/views/user/Register.vue
Normal file
@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div>
|
||||
<Message
|
||||
v-if="errorMessage !== ''"
|
||||
variant="danger"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</Message>
|
||||
<form
|
||||
id="registerform"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="username"
|
||||
>{{ $t('user.auth.username') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="username"
|
||||
v-model="credentials.username"
|
||||
v-focus
|
||||
class="input"
|
||||
name="username"
|
||||
:placeholder="$t('user.auth.usernamePlaceholder')"
|
||||
required
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
@keyup.enter="submit"
|
||||
@focusout="validateUsername"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!usernameValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.auth.usernameRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="email"
|
||||
>{{ $t('user.auth.email') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="email"
|
||||
v-model="credentials.email"
|
||||
class="input"
|
||||
name="email"
|
||||
:placeholder="$t('user.auth.emailPlaceholder')"
|
||||
required
|
||||
type="email"
|
||||
@keyup.enter="submit"
|
||||
@focusout="validateEmail"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!emailValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.auth.emailInvalid') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="password"
|
||||
>{{ $t('user.auth.password') }}</label>
|
||||
<Password
|
||||
:validate-initially="validatePasswordInitially"
|
||||
@submit="submit"
|
||||
@update:modelValue="v => credentials.password = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
id="register-submit"
|
||||
:loading="isLoading"
|
||||
class="mr-2"
|
||||
:disabled="!everythingValid"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('user.auth.createAccount') }}
|
||||
</x-button>
|
||||
|
||||
<Message
|
||||
v-if="configStore.demoModeEnabled"
|
||||
variant="warning"
|
||||
class="mt-4"
|
||||
>
|
||||
{{ $t('demo.title') }}
|
||||
{{ $t('demo.accountWillBeDeleted') }}<br>
|
||||
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
|
||||
</Message>
|
||||
|
||||
<p class="mt-2">
|
||||
{{ $t('user.auth.alreadyHaveAnAccount') }}
|
||||
<router-link :to="{ name: 'user.login' }">
|
||||
{{ $t('user.auth.login') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
import {ref, reactive, toRaw, computed, onBeforeMount} from 'vue'
|
||||
|
||||
import router from '@/router'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import {isEmail} from '@/helpers/isEmail'
|
||||
import Password from '@/components/input/password.vue'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// FIXME: use the `beforeEnter` hook of vue-router
|
||||
// Check if the user is already logged in, if so, redirect them to the homepage
|
||||
onBeforeMount(() => {
|
||||
if (authStore.authenticated) {
|
||||
router.push({name: 'home'})
|
||||
}
|
||||
})
|
||||
|
||||
const credentials = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const isLoading = computed(() => authStore.isLoading)
|
||||
const errorMessage = ref('')
|
||||
const validatePasswordInitially = ref(false)
|
||||
|
||||
const DEBOUNCE_TIME = 100
|
||||
|
||||
// debouncing to prevent error messages when clicking on the log in button
|
||||
const emailValid = ref(true)
|
||||
const validateEmail = useDebounceFn(() => {
|
||||
emailValid.value = isEmail(credentials.email)
|
||||
}, DEBOUNCE_TIME)
|
||||
|
||||
const usernameValid = ref(true)
|
||||
const validateUsername = useDebounceFn(() => {
|
||||
usernameValid.value = credentials.username !== ''
|
||||
}, DEBOUNCE_TIME)
|
||||
|
||||
const everythingValid = computed(() => {
|
||||
return credentials.username !== '' &&
|
||||
credentials.email !== '' &&
|
||||
credentials.password !== '' &&
|
||||
emailValid.value &&
|
||||
usernameValid.value
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
errorMessage.value = ''
|
||||
validatePasswordInitially.value = true
|
||||
|
||||
if (!everythingValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.register(toRaw(credentials))
|
||||
} catch (e) {
|
||||
errorMessage.value = e?.message
|
||||
}
|
||||
}
|
||||
</script>
|
94
frontend/src/views/user/RequestPasswordReset.vue
Normal file
94
frontend/src/views/user/RequestPasswordReset.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div>
|
||||
<Message
|
||||
v-if="errorMsg"
|
||||
variant="danger"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ errorMsg }}
|
||||
</Message>
|
||||
<div
|
||||
v-if="isSuccess"
|
||||
class="has-text-centered mb-4"
|
||||
>
|
||||
<Message variant="success">
|
||||
{{ $t('user.auth.resetPasswordSuccess') }}
|
||||
</Message>
|
||||
<x-button
|
||||
:to="{ name: 'user.login' }"
|
||||
class="mt-4"
|
||||
>
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<form
|
||||
v-if="!isSuccess"
|
||||
@submit.prevent="requestPasswordReset"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="email"
|
||||
>{{ $t('user.auth.email') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="email"
|
||||
v-model="passwordReset.email"
|
||||
v-focus
|
||||
class="input"
|
||||
name="email"
|
||||
:placeholder="$t('user.auth.emailPlaceholder')"
|
||||
required
|
||||
type="email"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<x-button
|
||||
type="submit"
|
||||
:loading="passwordResetService.loading"
|
||||
>
|
||||
{{ $t('user.auth.resetPasswordAction') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
:to="{ name: 'user.login' }"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('user.auth.login') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive} from 'vue'
|
||||
|
||||
import PasswordResetModel from '@/models/passwordReset'
|
||||
import PasswordResetService from '@/services/passwordReset'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
|
||||
const passwordResetService = shallowReactive(new PasswordResetService())
|
||||
const passwordReset = ref(new PasswordResetModel())
|
||||
const errorMsg = ref('')
|
||||
const isSuccess = ref(false)
|
||||
|
||||
async function requestPasswordReset() {
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
await passwordResetService.requestResetPassword(passwordReset.value)
|
||||
isSuccess.value = true
|
||||
} catch (e) {
|
||||
errorMsg.value = e.response.data.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button {
|
||||
margin: 0 0.4rem 0 0;
|
||||
}
|
||||
</style>
|
141
frontend/src/views/user/Settings.vue
Normal file
141
frontend/src/views/user/Settings.vue
Normal file
@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="content-widescreen">
|
||||
<div class="user-settings">
|
||||
<nav class="navigation">
|
||||
<ul>
|
||||
<li
|
||||
v-for="({routeName, title }, index) in navigationItems"
|
||||
:key="index"
|
||||
>
|
||||
<router-link
|
||||
class="navigation-link"
|
||||
:to="{name: routeName}"
|
||||
>
|
||||
{{ title }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<section class="view">
|
||||
<router-view />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTitle } from '@/composables/useTitle'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const { t } = useI18n({useScope: 'global'})
|
||||
useTitle(() => t('user.settings.title'))
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const totpEnabled = computed(() => configStore.totpEnabled)
|
||||
const caldavEnabled = computed(() => configStore.caldavEnabled)
|
||||
const migratorsEnabled = computed(() => configStore.migratorsEnabled)
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
const userDeletionEnabled = computed(() => configStore.userDeletionEnabled)
|
||||
|
||||
const navigationItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
title: t('user.settings.general.title'),
|
||||
routeName: 'user.settings.general',
|
||||
},
|
||||
{
|
||||
title: t('user.settings.newPasswordTitle'),
|
||||
routeName: 'user.settings.password-update',
|
||||
condition: isLocalUser.value,
|
||||
},
|
||||
{
|
||||
title: t('user.settings.updateEmailTitle'),
|
||||
routeName: 'user.settings.email-update',
|
||||
condition: isLocalUser.value,
|
||||
},
|
||||
{
|
||||
title: t('user.settings.avatar.title'),
|
||||
routeName: 'user.settings.avatar',
|
||||
},
|
||||
{
|
||||
title: t('user.settings.totp.title'),
|
||||
routeName: 'user.settings.totp',
|
||||
condition: totpEnabled.value,
|
||||
},
|
||||
{
|
||||
title: t('user.export.title'),
|
||||
routeName: 'user.settings.data-export',
|
||||
},
|
||||
{
|
||||
title: t('migrate.title'),
|
||||
routeName: 'migrate.start',
|
||||
condition: migratorsEnabled.value,
|
||||
},
|
||||
{
|
||||
title: t('user.settings.caldav.title'),
|
||||
routeName: 'user.settings.caldav',
|
||||
condition: caldavEnabled.value,
|
||||
},
|
||||
{
|
||||
title: t('user.settings.apiTokens.title'),
|
||||
routeName: 'user.settings.apiTokens',
|
||||
},
|
||||
{
|
||||
title: t('user.deletion.title'),
|
||||
routeName: 'user.settings.deletion',
|
||||
condition: userDeletionEnabled.value,
|
||||
},
|
||||
]
|
||||
|
||||
return items.filter(({condition}) => condition !== false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-settings {
|
||||
display: flex;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 25%;
|
||||
padding-right: 1rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
width: 100%;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-link {
|
||||
display: block;
|
||||
padding: .5rem;
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
border-left: 3px solid transparent;
|
||||
|
||||
&:hover,
|
||||
&.router-link-active {
|
||||
background: var(--white);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.view {
|
||||
width: 75%;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
width: 100%;
|
||||
padding-left: 0;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
353
frontend/src/views/user/settings/ApiTokens.vue
Normal file
353
frontend/src/views/user/settings/ApiTokens.vue
Normal file
@ -0,0 +1,353 @@
|
||||
<script setup lang="ts">
|
||||
import ApiTokenService from '@/services/apiToken'
|
||||
import {computed, onMounted, ref} from 'vue'
|
||||
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
|
||||
import XButton from '@/components/input/button.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ApiTokenModel from '@/models/apiTokenModel'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import {MILLISECONDS_A_DAY} from '@/constants/date'
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import type {IApiToken} from '@/modelTypes/IApiToken'
|
||||
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
||||
|
||||
const service = new ApiTokenService()
|
||||
const tokens = ref<IApiToken[]>([])
|
||||
const apiDocsUrl = window.API_URL + '/docs'
|
||||
const showCreateForm = ref(false)
|
||||
const availableRoutes = ref(null)
|
||||
const newToken = ref<IApiToken>(new ApiTokenModel())
|
||||
const newTokenExpiry = ref<string | number>(30)
|
||||
const newTokenExpiryCustom = ref(new Date())
|
||||
const newTokenPermissions = ref({})
|
||||
const newTokenPermissionsGroup = ref({})
|
||||
const newTokenTitleValid = ref(true)
|
||||
const apiTokenTitle = ref()
|
||||
const tokenCreatedSuccessMessage = ref('')
|
||||
|
||||
const showDeleteModal = ref<boolean>(false)
|
||||
const tokenToDelete = ref<IApiToken>()
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
locale: getFlatpickrLanguage(),
|
||||
minDate: now,
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
tokens.value = await service.getAll()
|
||||
availableRoutes.value = await service.getAvailableRoutes()
|
||||
resetPermissions()
|
||||
})
|
||||
|
||||
function resetPermissions() {
|
||||
newTokenPermissions.value = {}
|
||||
Object.entries(availableRoutes.value).forEach(entry => {
|
||||
const [group, routes] = entry
|
||||
newTokenPermissions.value[group] = {}
|
||||
Object.keys(routes).forEach(r => {
|
||||
newTokenPermissions.value[group][r] = false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteToken() {
|
||||
await service.delete(tokenToDelete.value)
|
||||
showDeleteModal.value = false
|
||||
const index = tokens.value.findIndex(el => el.id === tokenToDelete.value.id)
|
||||
tokenToDelete.value = null
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
tokens.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
if (!newTokenTitleValid.value) {
|
||||
apiTokenTitle.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const expiry = Number(newTokenExpiry.value)
|
||||
if (!isNaN(expiry)) {
|
||||
// if it's a number, we assume it's the number of days in the future
|
||||
newToken.value.expiresAt = new Date((+new Date()) + expiry * MILLISECONDS_A_DAY)
|
||||
} else {
|
||||
newToken.value.expiresAt = new Date(newTokenExpiryCustom.value)
|
||||
}
|
||||
|
||||
newToken.value.permissions = {}
|
||||
Object.entries(newTokenPermissions.value).forEach(([key, ps]) => {
|
||||
const all = Object.entries(ps)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.filter(([_, v]) => v)
|
||||
.map(p => p[0])
|
||||
if (all.length > 0) {
|
||||
newToken.value.permissions[key] = all
|
||||
}
|
||||
})
|
||||
|
||||
const token = await service.create(newToken.value)
|
||||
tokenCreatedSuccessMessage.value = t('user.settings.apiTokens.tokenCreatedSuccess', {token: token.token})
|
||||
newToken.value = new ApiTokenModel()
|
||||
newTokenExpiry.value = 30
|
||||
newTokenExpiryCustom.value = new Date()
|
||||
resetPermissions()
|
||||
tokens.value.push(token)
|
||||
showCreateForm.value = false
|
||||
}
|
||||
|
||||
function formatPermissionTitle(title: string): string {
|
||||
return title.replaceAll('_', ' ')
|
||||
}
|
||||
|
||||
function selectPermissionGroup(group: string, checked: boolean) {
|
||||
Object.entries(availableRoutes.value[group]).forEach(entry => {
|
||||
const [key] = entry
|
||||
newTokenPermissions.value[group][key] = checked
|
||||
})
|
||||
}
|
||||
|
||||
function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
|
||||
if (checked) {
|
||||
// Check if all permissions of that group are checked and check the "select all" checkbox in that case
|
||||
let allChecked = true
|
||||
Object.entries(availableRoutes.value[group]).forEach(entry => {
|
||||
const [key] = entry
|
||||
if (!newTokenPermissions.value[group][key]) {
|
||||
allChecked = false
|
||||
}
|
||||
})
|
||||
|
||||
if (allChecked) {
|
||||
newTokenPermissionsGroup.value[group] = true
|
||||
}
|
||||
} else {
|
||||
newTokenPermissionsGroup.value[group] = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<card :title="$t('user.settings.apiTokens.title')">
|
||||
<Message
|
||||
v-if="tokenCreatedSuccessMessage !== ''"
|
||||
class="has-text-centered mb-4"
|
||||
>
|
||||
{{ tokenCreatedSuccessMessage }}<br>
|
||||
{{ $t('user.settings.apiTokens.tokenCreatedNotSeeAgain') }}
|
||||
</Message>
|
||||
|
||||
<p>
|
||||
{{ $t('user.settings.apiTokens.general') }}
|
||||
<BaseButton :href="apiDocsUrl">
|
||||
{{ $t('user.settings.apiTokens.apiDocs') }}
|
||||
</BaseButton>
|
||||
.
|
||||
</p>
|
||||
|
||||
<table
|
||||
v-if="tokens.length > 0"
|
||||
class="table"
|
||||
>
|
||||
<tr>
|
||||
<th>{{ $t('misc.id') }}</th>
|
||||
<th>{{ $t('user.settings.apiTokens.attributes.title') }}</th>
|
||||
<th>{{ $t('user.settings.apiTokens.attributes.permissions') }}</th>
|
||||
<th>{{ $t('user.settings.apiTokens.attributes.expiresAt') }}</th>
|
||||
<th>{{ $t('misc.created') }}</th>
|
||||
<th class="has-text-right">
|
||||
{{ $t('misc.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="tk in tokens"
|
||||
:key="tk.id"
|
||||
>
|
||||
<td>{{ tk.id }}</td>
|
||||
<td>{{ tk.title }}</td>
|
||||
<td class="is-capitalized">
|
||||
<template
|
||||
v-for="(v, p) in tk.permissions"
|
||||
:key="'permission-' + p"
|
||||
>
|
||||
<strong>{{ formatPermissionTitle(p) }}:</strong>
|
||||
{{ v.map(formatPermissionTitle).join(', ') }}
|
||||
<br>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
{{ formatDateShort(tk.expiresAt) }}
|
||||
<p
|
||||
v-if="tk.expiresAt < new Date()"
|
||||
class="has-text-danger"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.expired', {ago: formatDateSince(tk.expiresAt)}) }}
|
||||
</p>
|
||||
</td>
|
||||
<td>{{ formatDateShort(tk.created) }}</td>
|
||||
<td class="has-text-right">
|
||||
<XButton
|
||||
variant="secondary"
|
||||
@click="() => {tokenToDelete = tk; showDeleteModal = true}"
|
||||
>
|
||||
{{ $t('misc.delete') }}
|
||||
</XButton>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<form
|
||||
v-if="showCreateForm"
|
||||
@submit.prevent="createToken"
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="apiTokenTitle"
|
||||
>{{ $t('user.settings.apiTokens.attributes.title') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="apiTokenTitle"
|
||||
ref="apiTokenTitle"
|
||||
v-model="newToken.title"
|
||||
v-focus
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('user.settings.apiTokens.attributes.titlePlaceholder')"
|
||||
@keyup="() => newTokenTitleValid = newToken.title !== ''"
|
||||
@focusout="() => newTokenTitleValid = newToken.title !== ''"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!newTokenTitleValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.titleRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Expiry -->
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="apiTokenExpiry"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.attributes.expiresAt') }}
|
||||
</label>
|
||||
<div class="is-flex">
|
||||
<div class="control select">
|
||||
<select
|
||||
id="apiTokenExpiry"
|
||||
v-model="newTokenExpiry"
|
||||
class="select"
|
||||
>
|
||||
<option value="30">
|
||||
{{ $t('user.settings.apiTokens.30d') }}
|
||||
</option>
|
||||
<option value="60">
|
||||
{{ $t('user.settings.apiTokens.60d') }}
|
||||
</option>
|
||||
<option value="90">
|
||||
{{ $t('user.settings.apiTokens.90d') }}
|
||||
</option>
|
||||
<option value="custom">
|
||||
{{ $t('misc.custom') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<flat-pickr
|
||||
v-if="newTokenExpiry === 'custom'"
|
||||
v-model="newTokenExpiryCustom"
|
||||
class="ml-2"
|
||||
:config="flatPickerConfig"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permissions -->
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>
|
||||
<p>{{ $t('user.settings.apiTokens.permissionExplanation') }}</p>
|
||||
<div
|
||||
v-for="(routes, group) in availableRoutes"
|
||||
:key="group"
|
||||
class="mb-2"
|
||||
>
|
||||
<strong class="is-capitalized">{{ formatPermissionTitle(group) }}</strong><br>
|
||||
<template
|
||||
v-if="Object.keys(routes).length > 1"
|
||||
>
|
||||
<Fancycheckbox
|
||||
v-model="newTokenPermissionsGroup[group]"
|
||||
class="mr-2 is-italic"
|
||||
@update:modelValue="checked => selectPermissionGroup(group, checked)"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.selectAll') }}
|
||||
</Fancycheckbox>
|
||||
<br>
|
||||
</template>
|
||||
<template
|
||||
v-for="(paths, route) in routes"
|
||||
:key="group+'-'+route"
|
||||
>
|
||||
<Fancycheckbox
|
||||
v-model="newTokenPermissions[group][route]"
|
||||
class="mr-2 is-capitalized"
|
||||
@update:modelValue="checked => toggleGroupPermissionsFromChild(group, checked)"
|
||||
>
|
||||
{{ formatPermissionTitle(route) }}
|
||||
</Fancycheckbox>
|
||||
<br>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<XButton
|
||||
:loading="service.loading"
|
||||
@click="createToken"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.createToken') }}
|
||||
</XButton>
|
||||
</form>
|
||||
|
||||
<XButton
|
||||
v-else
|
||||
icon="plus"
|
||||
class="mb-4"
|
||||
:loading="service.loading"
|
||||
@click="() => showCreateForm = true"
|
||||
>
|
||||
{{ $t('user.settings.apiTokens.createAToken') }}
|
||||
</XButton>
|
||||
|
||||
<modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteToken()"
|
||||
>
|
||||
<template #header>
|
||||
{{ $t('user.settings.apiTokens.delete.header') }}
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('user.settings.apiTokens.delete.text1', {token: tokenToDelete.title}) }}<br>
|
||||
{{ $t('user.settings.apiTokens.delete.text2') }}
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
</card>
|
||||
</template>
|
168
frontend/src/views/user/settings/Avatar.vue
Normal file
168
frontend/src/views/user/settings/Avatar.vue
Normal file
@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<card :title="$t('user.settings.avatar.title')">
|
||||
<div class="control mb-4">
|
||||
<label
|
||||
v-for="(label, providerId) in AVATAR_PROVIDERS"
|
||||
:key="providerId"
|
||||
class="radio"
|
||||
>
|
||||
<input
|
||||
v-model="avatarProvider"
|
||||
name="avatarProvider"
|
||||
type="radio"
|
||||
:value="providerId"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="avatarProvider === 'upload'">
|
||||
<input
|
||||
ref="avatarUploadInput"
|
||||
accept="image/*"
|
||||
class="is-hidden"
|
||||
type="file"
|
||||
@change="cropAvatar"
|
||||
>
|
||||
|
||||
<x-button
|
||||
v-if="!isCropAvatar"
|
||||
:loading="avatarService.loading || loading"
|
||||
@click="avatarUploadInput.click()"
|
||||
>
|
||||
{{ $t('user.settings.avatar.uploadAvatar') }}
|
||||
</x-button>
|
||||
<template v-else>
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
:src="avatarToCrop"
|
||||
:stencil-props="{aspectRatio: 1}"
|
||||
class="mb-4 cropper"
|
||||
@ready="() => loading = false"
|
||||
/>
|
||||
<x-button
|
||||
v-cy="'uploadAvatar'"
|
||||
:loading="avatarService.loading || loading"
|
||||
@click="uploadAvatar"
|
||||
>
|
||||
{{ $t('user.settings.avatar.uploadAvatar') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="mt-2"
|
||||
>
|
||||
<x-button
|
||||
:loading="avatarService.loading || loading"
|
||||
class="is-fullwidth"
|
||||
@click="updateAvatarStatus()"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'UserSettingsAvatar' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {Cropper} from 'vue-advanced-cropper'
|
||||
import 'vue-advanced-cropper/dist/style.css'
|
||||
|
||||
import AvatarService from '@/services/avatar'
|
||||
import AvatarModel from '@/models/avatar'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const AVATAR_PROVIDERS = computed(() => ({
|
||||
default: t('misc.default'),
|
||||
initials: t('user.settings.avatar.initials'),
|
||||
gravatar: t('user.settings.avatar.gravatar'),
|
||||
marble: t('user.settings.avatar.marble'),
|
||||
upload: t('user.settings.avatar.upload'),
|
||||
}))
|
||||
|
||||
useTitle(() => `${t('user.settings.avatar.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
const avatarService = shallowReactive(new AvatarService())
|
||||
// Seperate variable because some things we're doing in browser take a bit
|
||||
const loading = ref(false)
|
||||
|
||||
|
||||
const avatarProvider = ref('')
|
||||
async function avatarStatus() {
|
||||
const { avatarProvider: currentProvider } = await avatarService.get({})
|
||||
avatarProvider.value = currentProvider
|
||||
}
|
||||
avatarStatus()
|
||||
|
||||
|
||||
async function updateAvatarStatus() {
|
||||
await avatarService.update(new AvatarModel({avatarProvider: avatarProvider.value}))
|
||||
success({message: t('user.settings.avatar.statusUpdateSuccess')})
|
||||
authStore.reloadAvatar()
|
||||
}
|
||||
|
||||
const cropper = ref()
|
||||
const isCropAvatar = ref(false)
|
||||
|
||||
async function uploadAvatar() {
|
||||
loading.value = true
|
||||
const {canvas} = cropper.value.getResult()
|
||||
|
||||
if (!canvas) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await new Promise(resolve => canvas.toBlob(blob => resolve(blob)))
|
||||
await avatarService.create(blob)
|
||||
success({message: t('user.settings.avatar.setSuccess')})
|
||||
authStore.reloadAvatar()
|
||||
} finally {
|
||||
loading.value = false
|
||||
isCropAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const avatarToCrop = ref()
|
||||
const avatarUploadInput = ref()
|
||||
function cropAvatar() {
|
||||
const avatar = avatarUploadInput.value.files
|
||||
|
||||
if (avatar.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
const reader = new FileReader()
|
||||
reader.onload = e => {
|
||||
avatarToCrop.value = e.target.result
|
||||
isCropAvatar.value = true
|
||||
}
|
||||
reader.onloadend = () => loading.value = false
|
||||
reader.readAsDataURL(avatar[0])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.cropper {
|
||||
height: 80vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.vue-advanced-cropper__background {
|
||||
background: var(--white);
|
||||
}
|
||||
</style>
|
147
frontend/src/views/user/settings/Caldav.vue
Normal file
147
frontend/src/views/user/settings/Caldav.vue
Normal file
@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<card
|
||||
v-if="caldavEnabled"
|
||||
:title="$t('user.settings.caldav.title')"
|
||||
>
|
||||
<p>
|
||||
{{ $t('user.settings.caldav.howTo') }}
|
||||
</p>
|
||||
<div class="field has-addons no-input-mobile">
|
||||
<div class="control is-expanded">
|
||||
<input
|
||||
v-model="caldavUrl"
|
||||
type="text"
|
||||
class="input"
|
||||
readonly
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
v-tooltip="$t('misc.copy')"
|
||||
:shadow="false"
|
||||
icon="paste"
|
||||
@click="copy(caldavUrl)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-5 mb-4 has-text-weight-bold">
|
||||
{{ $t('user.settings.caldav.tokens') }}
|
||||
</h5>
|
||||
|
||||
<p>
|
||||
{{ isLocalUser ? $t('user.settings.caldav.tokensHowTo') : $t('user.settings.caldav.mustUseToken') }}
|
||||
<template v-if="!isLocalUser">
|
||||
<br>
|
||||
<i18n-t
|
||||
keypath="user.settings.caldav.usernameIs"
|
||||
scope="global"
|
||||
>
|
||||
<strong>{{ username }}</strong>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<table
|
||||
v-if="tokens.length > 0"
|
||||
class="table"
|
||||
>
|
||||
<tr>
|
||||
<th>{{ $t('misc.id') }}</th>
|
||||
<th>{{ $t('misc.created') }}</th>
|
||||
<th class="has-text-right">
|
||||
{{ $t('misc.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="tk in tokens"
|
||||
:key="tk.id"
|
||||
>
|
||||
<td>{{ tk.id }}</td>
|
||||
<td>{{ formatDateShort(tk.created) }}</td>
|
||||
<td class="has-text-right">
|
||||
<x-button
|
||||
variant="secondary"
|
||||
@click="deleteToken(tk)"
|
||||
>
|
||||
{{ $t('misc.delete') }}
|
||||
</x-button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<Message
|
||||
v-if="newToken"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ $t('user.settings.caldav.tokenCreated', {token: newToken.token}) }}<br>
|
||||
{{ $t('user.settings.caldav.wontSeeItAgain') }}
|
||||
</Message>
|
||||
|
||||
<x-button
|
||||
icon="plus"
|
||||
class="mb-4"
|
||||
:loading="service.loading"
|
||||
@click="createToken"
|
||||
>
|
||||
{{ $t('user.settings.caldav.createToken') }}
|
||||
</x-button>
|
||||
|
||||
<p>
|
||||
<BaseButton
|
||||
:href="CALDAV_DOCS"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('user.settings.caldav.more') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {CALDAV_DOCS} from '@/urls'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
||||
import {success} from '@/message'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import CaldavTokenService from '@/services/caldavToken'
|
||||
import { formatDateShort } from '@/helpers/time/formatDate'
|
||||
import type {ICaldavToken} from '@/modelTypes/ICaldavToken'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const copy = useCopyToClipboard()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.settings.caldav.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
const service = shallowReactive(new CaldavTokenService())
|
||||
const tokens = ref<ICaldavToken[]>([])
|
||||
|
||||
service.getAll().then((result: ICaldavToken[]) => {
|
||||
tokens.value = result
|
||||
})
|
||||
|
||||
const newToken = ref<ICaldavToken>()
|
||||
async function createToken() {
|
||||
newToken.value = await service.create({}) as ICaldavToken
|
||||
tokens.value.push(newToken.value)
|
||||
}
|
||||
|
||||
async function deleteToken(token: ICaldavToken) {
|
||||
const r = await service.delete(token)
|
||||
tokens.value = tokens.value.filter(({id}) => id !== token.id)
|
||||
success(r)
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const username = computed(() => authStore.info?.username)
|
||||
const caldavUrl = computed(() => `${configStore.apiBase}/dav/principals/${username.value}/`)
|
||||
const caldavEnabled = computed(() => configStore.caldavEnabled)
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
</script>
|
83
frontend/src/views/user/settings/DataExport.vue
Normal file
83
frontend/src/views/user/settings/DataExport.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<card :title="$t('user.export.title')">
|
||||
<p>
|
||||
{{ $t('user.export.description') }}
|
||||
</p>
|
||||
<template v-if="isLocalUser">
|
||||
<p>
|
||||
{{ $t('user.export.descriptionPasswordRequired') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPasswordDataExport"
|
||||
>
|
||||
{{ $t('user.settings.currentPassword') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPasswordDataExport"
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
class="input"
|
||||
:class="{'is-danger': errPasswordRequired}"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup="() => errPasswordRequired = password === ''"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="errPasswordRequired"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.deletion.passwordRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<x-button
|
||||
:loading="dataExportService.loading"
|
||||
class="is-fullwidth mt-4"
|
||||
@click="requestDataExport()"
|
||||
>
|
||||
{{ $t('user.export.request') }}
|
||||
</x-button>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'UserSettingsDataExport'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import DataExportService from '@/services/dataExport'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const authStore = useAuthStore()
|
||||
|
||||
useTitle(() => `${t('user.export.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
const dataExportService = shallowReactive(new DataExportService())
|
||||
const password = ref('')
|
||||
const errPasswordRequired = ref(false)
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
const passwordInput = ref()
|
||||
|
||||
async function requestDataExport() {
|
||||
if (password.value === '' && isLocalUser.value) {
|
||||
errPasswordRequired.value = true
|
||||
passwordInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
await dataExportService.request(password.value)
|
||||
success({message: t('user.export.success')})
|
||||
password.value = ''
|
||||
}
|
||||
</script>
|
170
frontend/src/views/user/settings/Deletion.vue
Normal file
170
frontend/src/views/user/settings/Deletion.vue
Normal file
@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<card
|
||||
v-if="userDeletionEnabled"
|
||||
:title="$t('user.deletion.title')"
|
||||
>
|
||||
<template v-if="deletionScheduledAt !== null">
|
||||
<form @submit.prevent="cancelDeletion()">
|
||||
<p>
|
||||
{{
|
||||
$t('user.deletion.scheduled', {
|
||||
date: formatDateShort(deletionScheduledAt),
|
||||
dateSince: formatDateSince(deletionScheduledAt),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<template v-if="isLocalUser">
|
||||
<p>
|
||||
{{ $t('user.deletion.scheduledCancelText') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPasswordAccountDelete"
|
||||
>
|
||||
{{ $t('user.settings.currentPassword') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPasswordAccountDelete"
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
class="input"
|
||||
:class="{'is-danger': errPasswordRequired}"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup="() => errPasswordRequired = password === ''"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="errPasswordRequired"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.deletion.passwordRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else>
|
||||
{{ $t('user.deletion.scheduledCancelButton') }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<x-button
|
||||
:loading="accountDeleteService.loading"
|
||||
class="is-fullwidth mt-4"
|
||||
@click="cancelDeletion()"
|
||||
>
|
||||
{{ $t('user.deletion.scheduledCancelConfirm') }}
|
||||
</x-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>
|
||||
{{ $t('user.deletion.text1') }}
|
||||
</p>
|
||||
<form
|
||||
v-if="isLocalUser"
|
||||
@submit.prevent="deleteAccount()"
|
||||
>
|
||||
<p>
|
||||
{{ $t('user.deletion.text2') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPasswordAccountDelete"
|
||||
>
|
||||
{{ $t('user.settings.currentPassword') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPasswordAccountDelete"
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
class="input"
|
||||
:class="{'is-danger': errPasswordRequired}"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup="() => errPasswordRequired = password === ''"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="errPasswordRequired"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('user.deletion.passwordRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
<p v-else>
|
||||
{{ $t('user.deletion.text3') }}
|
||||
</p>
|
||||
|
||||
<x-button
|
||||
:loading="accountDeleteService.loading"
|
||||
class="is-fullwidth mt-4 is-danger"
|
||||
@click="deleteAccount()"
|
||||
>
|
||||
{{ $t('user.deletion.confirm') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'UserSettingsDeletion'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive, computed} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import AccountDeleteService from '@/services/accountDelete'
|
||||
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
||||
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.deletion.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
const accountDeleteService = shallowReactive(new AccountDeleteService())
|
||||
const password = ref('')
|
||||
const errPasswordRequired = ref(false)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const userDeletionEnabled = computed(() => configStore.userDeletionEnabled)
|
||||
const deletionScheduledAt = computed(() => parseDateOrNull(authStore.info?.deletionScheduledAt))
|
||||
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
|
||||
const passwordInput = ref()
|
||||
|
||||
async function deleteAccount() {
|
||||
if (isLocalUser.value && password.value === '') {
|
||||
errPasswordRequired.value = true
|
||||
passwordInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
await accountDeleteService.request(password.value)
|
||||
success({message: t('user.deletion.requestSuccess')})
|
||||
password.value = ''
|
||||
}
|
||||
|
||||
async function cancelDeletion() {
|
||||
if (isLocalUser.value && password.value === '') {
|
||||
errPasswordRequired.value = true
|
||||
passwordInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
await accountDeleteService.cancel(password.value)
|
||||
success({message: t('user.deletion.scheduledCancelSuccess')})
|
||||
authStore.refreshUserInfo()
|
||||
password.value = ''
|
||||
}
|
||||
</script>
|
77
frontend/src/views/user/settings/EmailUpdate.vue
Normal file
77
frontend/src/views/user/settings/EmailUpdate.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<card
|
||||
v-if="isLocalUser"
|
||||
:title="$t('user.settings.updateEmailTitle')"
|
||||
>
|
||||
<form @submit.prevent="updateEmail">
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="newEmail"
|
||||
>{{ $t('user.settings.updateEmailNew') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="newEmail"
|
||||
v-model="emailUpdate.newEmail"
|
||||
class="input"
|
||||
:placeholder="$t('user.auth.emailPlaceholder')"
|
||||
type="email"
|
||||
@keyup.enter="updateEmail"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPasswordEmail"
|
||||
>{{ $t('user.settings.currentPassword') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPasswordEmail"
|
||||
v-model="emailUpdate.password"
|
||||
class="input"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup.enter="updateEmail"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<x-button
|
||||
:loading="emailUpdateService.loading"
|
||||
class="is-fullwidth mt-4"
|
||||
@click="updateEmail"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'UserSettingsUpdateEmail' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {reactive, computed, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import EmailUpdateService from '@/services/emailUpdate'
|
||||
import EmailUpdateModel from '@/models/emailUpdate'
|
||||
import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.settings.updateEmailTitle')} - ${t('user.settings.title')}`)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
|
||||
const emailUpdate = reactive(new EmailUpdateModel())
|
||||
const emailUpdateService = shallowReactive(new EmailUpdateService())
|
||||
async function updateEmail() {
|
||||
await emailUpdateService.update(emailUpdate)
|
||||
success({message: t('user.settings.updateEmailSuccess')})
|
||||
}
|
||||
</script>
|
304
frontend/src/views/user/settings/General.vue
Normal file
304
frontend/src/views/user/settings/General.vue
Normal file
@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<card
|
||||
:title="$t('user.settings.general.title')"
|
||||
class="general-settings"
|
||||
:loading="loading"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
:for="`newName${id}`"
|
||||
>{{ $t('user.settings.general.name') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
:id="`newName${id}`"
|
||||
v-model="settings.name"
|
||||
class="input"
|
||||
:placeholder="$t('user.settings.general.newName')"
|
||||
type="text"
|
||||
@keyup.enter="updateSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
{{ $t('user.settings.general.defaultProject') }}
|
||||
</label>
|
||||
<ProjectSearch v-model="defaultProject" />
|
||||
</div>
|
||||
<div
|
||||
v-if="hasFilters"
|
||||
class="field"
|
||||
>
|
||||
<label class="label">
|
||||
{{ $t('user.settings.general.filterUsedOnOverview') }}
|
||||
</label>
|
||||
<ProjectSearch
|
||||
v-model="filterUsedInOverview"
|
||||
:saved-filters-only="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="settings.emailRemindersEnabled"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.emailReminders') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="settings.discoverableByName"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.discoverableByName') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="settings.discoverableByEmail"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.discoverableByEmail') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="settings.frontendSettings.playSoundWhenDone"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.playSoundWhenDone') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="settings.overdueTasksRemindersEnabled"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.overdueReminders') }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="settings.overdueTasksRemindersEnabled"
|
||||
class="field"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
for="overdueTasksReminderTime"
|
||||
>
|
||||
{{ $t('user.settings.general.overdueTasksRemindersTime') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="overdueTasksReminderTime"
|
||||
v-model="settings.overdueTasksRemindersTime"
|
||||
class="input"
|
||||
type="time"
|
||||
@keyup.enter="updateSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="is-flex is-align-items-center">
|
||||
<span>
|
||||
{{ $t('user.settings.general.weekStart') }}
|
||||
</span>
|
||||
<div class="select ml-2">
|
||||
<select v-model.number="settings.weekStart">
|
||||
<option value="0">{{ $t('user.settings.general.weekStartSunday') }}</option>
|
||||
<option value="1">{{ $t('user.settings.general.weekStartMonday') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="is-flex is-align-items-center">
|
||||
<span>
|
||||
{{ $t('user.settings.general.language') }}
|
||||
</span>
|
||||
<div class="select ml-2">
|
||||
<select v-model="settings.language">
|
||||
<option
|
||||
v-for="lang in availableLanguageOptions"
|
||||
:key="lang.code"
|
||||
:value="lang.code"
|
||||
>{{ lang.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="is-flex is-align-items-center">
|
||||
<span>
|
||||
{{ $t('user.settings.quickAddMagic.title') }}
|
||||
</span>
|
||||
<div class="select ml-2">
|
||||
<select v-model="settings.frontendSettings.quickAddMagicMode">
|
||||
<option
|
||||
v-for="set in PrefixMode"
|
||||
:key="set"
|
||||
:value="set"
|
||||
>
|
||||
{{ $t(`user.settings.quickAddMagic.${set}`) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="is-flex is-align-items-center">
|
||||
<span>
|
||||
{{ $t('user.settings.appearance.title') }}
|
||||
</span>
|
||||
<div class="select ml-2">
|
||||
<select v-model="settings.frontendSettings.colorSchema">
|
||||
<!-- TODO: use the Vikunja logo in color scheme as option buttons -->
|
||||
<option
|
||||
v-for="(title, schemeId) in colorSchemeSettings"
|
||||
:key="schemeId"
|
||||
:value="schemeId"
|
||||
>
|
||||
{{ title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="is-flex is-align-items-center">
|
||||
<span>
|
||||
{{ $t('user.settings.general.timezone') }}
|
||||
</span>
|
||||
<div class="select ml-2">
|
||||
<select v-model="settings.timezone">
|
||||
<option
|
||||
v-for="tz in availableTimezones"
|
||||
:key="tz"
|
||||
>
|
||||
{{ tz }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
v-cy="'saveGeneralSettings'"
|
||||
:loading="loading"
|
||||
class="is-fullwidth mt-4"
|
||||
@click="updateSettings()"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'UserSettingsGeneral'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, watch, ref} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {PrefixMode} from '@/modules/parseTaskText'
|
||||
|
||||
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||
|
||||
import {SUPPORTED_LOCALES} from '@/i18n'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
import {AuthenticatedHTTPFactory} from '@/helpers/fetcher'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import type {IUserSettings} from '@/modelTypes/IUserSettings'
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.settings.general.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
const DEFAULT_PROJECT_ID = 0
|
||||
|
||||
const colorSchemeSettings = computed(() => ({
|
||||
light: t('user.settings.appearance.colorScheme.light'),
|
||||
auto: t('user.settings.appearance.colorScheme.system'),
|
||||
dark: t('user.settings.appearance.colorScheme.dark'),
|
||||
}))
|
||||
|
||||
function useAvailableTimezones() {
|
||||
const availableTimezones = ref([])
|
||||
|
||||
const HTTP = AuthenticatedHTTPFactory()
|
||||
HTTP.get('user/timezones')
|
||||
.then(r => {
|
||||
if (r.data) {
|
||||
availableTimezones.value = r.data.sort()
|
||||
return
|
||||
}
|
||||
|
||||
availableTimezones.value = []
|
||||
})
|
||||
|
||||
return availableTimezones
|
||||
}
|
||||
|
||||
const availableTimezones = useAvailableTimezones()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const settings = ref<IUserSettings>({
|
||||
...authStore.settings,
|
||||
frontendSettings: {
|
||||
// Sub objects get exported as read only as well, so we need to
|
||||
// explicitly spread the object here to allow modification
|
||||
...authStore.settings.frontendSettings,
|
||||
},
|
||||
})
|
||||
const id = ref(createRandomID())
|
||||
const availableLanguageOptions = ref(
|
||||
Object.entries(SUPPORTED_LOCALES)
|
||||
.map(l => ({code: l[0], title: l[1]}))
|
||||
.sort((a, b) => a.title.localeCompare(b.title)),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => authStore.settings,
|
||||
() => {
|
||||
// Only set setting if we don't have edited values yet to avoid overriding
|
||||
if (Object.keys(settings.value).length !== 0) {
|
||||
return
|
||||
}
|
||||
settings.value = {...authStore.settings}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const defaultProject = computed({
|
||||
get: () => projectStore.projects[settings.value.defaultProjectId],
|
||||
set(l) {
|
||||
settings.value.defaultProjectId = l ? l.id : DEFAULT_PROJECT_ID
|
||||
},
|
||||
})
|
||||
const filterUsedInOverview = computed({
|
||||
get: () => projectStore.projects[settings.value.frontendSettings.filterIdUsedOnOverview],
|
||||
set(l) {
|
||||
settings.value.frontendSettings.filterIdUsedOnOverview = l ? l.id : null
|
||||
},
|
||||
})
|
||||
const hasFilters = computed(() => typeof projectStore.projectsArray.find(p => isSavedFilter(p)) !== 'undefined')
|
||||
const loading = computed(() => authStore.isLoadingGeneralSettings)
|
||||
|
||||
async function updateSettings() {
|
||||
await authStore.saveUserSettings({
|
||||
settings: {...settings.value},
|
||||
})
|
||||
}
|
||||
</script>
|
105
frontend/src/views/user/settings/PasswordUpdate.vue
Normal file
105
frontend/src/views/user/settings/PasswordUpdate.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<card
|
||||
v-if="isLocalUser"
|
||||
:title="$t('user.settings.newPasswordTitle')"
|
||||
:loading="passwordUpdateService.loading"
|
||||
>
|
||||
<form @submit.prevent="updatePassword">
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="newPassword"
|
||||
>{{ $t('user.settings.newPassword') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="newPassword"
|
||||
v-model="passwordUpdate.newPassword"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||
type="password"
|
||||
@keyup.enter="updatePassword"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="newPasswordConfirm"
|
||||
>{{ $t('user.settings.newPasswordConfirm') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="newPasswordConfirm"
|
||||
v-model="passwordConfirm"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||
type="password"
|
||||
@keyup.enter="updatePassword"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPassword"
|
||||
>{{ $t('user.settings.currentPassword') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPassword"
|
||||
v-model="passwordUpdate.oldPassword"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup.enter="updatePassword"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<x-button
|
||||
:loading="passwordUpdateService.loading"
|
||||
class="is-fullwidth mt-4"
|
||||
@click="updatePassword"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'UserSettingsPasswordUpdate'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, shallowReactive, computed} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import PasswordUpdateService from '@/services/passwordUpdateService'
|
||||
import PasswordUpdateModel from '@/models/passwordUpdate'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success, error} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const passwordUpdateService = shallowReactive(new PasswordUpdateService())
|
||||
const passwordUpdate = reactive(new PasswordUpdateModel())
|
||||
const passwordConfirm = ref('')
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.settings.newPasswordTitle')} - ${t('user.settings.title')}`)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||
|
||||
async function updatePassword() {
|
||||
if (passwordConfirm.value !== passwordUpdate.newPassword) {
|
||||
error({message: t('user.settings.passwordsDontMatch')})
|
||||
return
|
||||
}
|
||||
|
||||
await passwordUpdateService.update(passwordUpdate)
|
||||
success({message: t('user.settings.passwordUpdateSuccess')})
|
||||
}
|
||||
</script>
|
171
frontend/src/views/user/settings/TOTP.vue
Normal file
171
frontend/src/views/user/settings/TOTP.vue
Normal file
@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<card
|
||||
v-if="totpEnabled"
|
||||
:title="$t('user.settings.totp.title')"
|
||||
>
|
||||
<x-button
|
||||
v-if="!totpEnrolled && totp.secret === ''"
|
||||
:loading="totpService.loading"
|
||||
@click="totpEnroll()"
|
||||
>
|
||||
{{ $t('user.settings.totp.enroll') }}
|
||||
</x-button>
|
||||
<template v-else-if="totp.secret !== '' && !totp.enabled">
|
||||
<p>
|
||||
{{ $t('user.settings.totp.finishSetupPart1') }}
|
||||
<strong>{{ totp.secret }}</strong><br>
|
||||
{{ $t('user.settings.totp.finishSetupPart2') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('user.settings.totp.scanQR') }}<br>
|
||||
<img
|
||||
:src="totpQR"
|
||||
alt=""
|
||||
>
|
||||
</p>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="totpConfirmPasscode"
|
||||
>{{ $t('user.settings.totp.passcode') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="totpConfirmPasscode"
|
||||
v-model="totpConfirmPasscode"
|
||||
autocomplete="one-time-code"
|
||||
class="input"
|
||||
:placeholder="$t('user.settings.totp.passcodePlaceholder')"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
@keyup.enter="totpConfirm"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<x-button @click="totpConfirm">
|
||||
{{ $t('misc.confirm') }}
|
||||
</x-button>
|
||||
</template>
|
||||
<template v-else-if="totp.secret !== '' && totp.enabled">
|
||||
<p>
|
||||
{{ $t('user.settings.totp.setupSuccess') }}
|
||||
</p>
|
||||
<p v-if="!totpDisableForm">
|
||||
<x-button
|
||||
class="is-danger"
|
||||
@click="totpDisableForm = true"
|
||||
>
|
||||
{{ $t('misc.disable') }}
|
||||
</x-button>
|
||||
</p>
|
||||
<div v-if="totpDisableForm">
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="currentPassword"
|
||||
>{{ $t('user.settings.totp.enterPassword') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="currentPassword"
|
||||
v-model="totpDisablePassword"
|
||||
v-focus
|
||||
class="input"
|
||||
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
|
||||
type="password"
|
||||
@keyup.enter="totpDisable"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<x-button
|
||||
class="is-danger"
|
||||
@click="totpDisable"
|
||||
>
|
||||
{{ $t('user.settings.totp.disable') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
variant="tertiary"
|
||||
class="ml-2"
|
||||
@click="totpDisableForm = false"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</template>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'UserSettingsTotp' }
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import TotpService from '@/services/totp'
|
||||
import TotpModel from '@/models/totp'
|
||||
|
||||
import {success} from '@/message'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import type {ITotp} from '@/modelTypes/ITotp'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.settings.totp.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
|
||||
const totpService = shallowReactive(new TotpService())
|
||||
const totp = ref<ITotp>(new TotpModel())
|
||||
const totpQR = ref('')
|
||||
const totpEnrolled = ref(false)
|
||||
const totpConfirmPasscode = ref('')
|
||||
const totpDisableForm = ref(false)
|
||||
const totpDisablePassword = ref('')
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const totpEnabled = computed(() => configStore.totpEnabled)
|
||||
|
||||
totpStatus()
|
||||
|
||||
async function totpStatus() {
|
||||
if (!totpEnabled.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
totp.value = await totpService.get()
|
||||
totpSetQrCode()
|
||||
} catch(e) {
|
||||
// Error code 1016 means totp is not enabled, we don't need an error in that case.
|
||||
if (e.response?.data?.code === 1016) {
|
||||
totpEnrolled.value = false
|
||||
return
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function totpSetQrCode() {
|
||||
const qr = await totpService.qrcode()
|
||||
totpQR.value = window.URL.createObjectURL(qr)
|
||||
}
|
||||
|
||||
async function totpEnroll() {
|
||||
totp.value = await totpService.enroll()
|
||||
totpEnrolled.value = true
|
||||
totpSetQrCode()
|
||||
}
|
||||
|
||||
async function totpConfirm() {
|
||||
await totpService.enable({passcode: totpConfirmPasscode.value})
|
||||
totp.value.enabled = true
|
||||
success({message: t('user.settings.totp.confirmSuccess')})
|
||||
}
|
||||
|
||||
async function totpDisable() {
|
||||
await totpService.disable({password: totpDisablePassword.value})
|
||||
totpEnrolled.value = false
|
||||
totp.value = new TotpModel()
|
||||
success({message: t('user.settings.totp.disableSuccess')})
|
||||
}
|
||||
</script>
|
Reference in New Issue
Block a user