Compare commits

...

24 Commits

Author SHA1 Message Date
WofWca e2e743708c chore: rename `dist.xdc` -> `vikunja.xdc` 2023-03-28 16:24:54 +04:00
WofWca d88126d366 refactor: rename the GitHub Actions file 2023-03-28 16:24:54 +04:00
WofWca ac8c9c7924 refactor: update workflow name 2023-03-28 16:24:54 +04:00
WofWca 0e72842668 chore: add GitHub action to make a release with `.xdc` file 2023-03-28 16:24:54 +04:00
WofWca bcd1528faa add webxdc build script 2023-03-28 16:24:54 +04:00
WofWca 1352a82c8c use hash history so it works on GitHub Pages 2023-03-28 16:24:54 +04:00
WofWca 19f99af023 Create .github/workflows/static.yml 2023-03-28 16:24:54 +04:00
WofWca 80ba017a74 redirect to first list 2023-03-28 16:24:54 +04:00
WofWca 90ef2b13a9 buckets and some more 2023-03-28 16:24:54 +04:00
WofWca f4890f00d7 adjust config 2023-03-28 16:24:54 +04:00
WofWca 724daee7da WIP 2023-03-28 16:24:54 +04:00
WofWca 6b47f1ba47 WIP 2023-03-28 16:24:54 +04:00
Dominik Pschenitschni 91e9eef582 fix: use strict comparison 2023-03-28 10:49:34 +00:00
Dominik Pschenitschni dea1789a00
feat: type i18n improvements 2023-03-28 12:35:19 +02:00
WofWca 30adad5ae6 feat: mark undone if task moved from isDoneBucket (#3291)
Addresses #545 (not completely)

Reviewed-on: vikunja/frontend#3291
Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Reviewed-by: konrad <k@knt.li>
Co-authored-by: WofWca <wofwca@protonmail.com>
Co-committed-by: WofWca <wofwca@protonmail.com>
2023-03-28 10:21:19 +00:00
renovate 3ed6f939e5 chore(deps): update dependency vite-plugin-pwa to v0.14.7 2023-03-28 10:05:08 +00:00
renovate a337d22c1f fix(deps): update font awesome to v6.4.0 2023-03-27 18:27:34 +00:00
renovate addfcf2510 chore(deps): update typescript-eslint monorepo to v5.57.0 2023-03-27 18:05:13 +00:00
renovate 303034f02c chore(deps): update pnpm to v7.30.5 2023-03-27 14:04:54 +00:00
renovate 0fd44e9484 chore(deps): update dependency caniuse-lite to v1.0.30001470 2023-03-27 06:06:31 +00:00
renovate 04040f20ba chore(deps): update dependency netlify-cli to v13.2.1 2023-03-27 00:06:51 +00:00
konrad 6c999ad148 fix: ensure same protocol for configured api url (#3303)
Resolves https://github.com/go-vikunja/frontend/issues/109

Vikunja would save the api url with `http` instead of `https` when the frontend was accessed via https. This was fine in most cases when the server would redirect all requests made to http to the secure https variant. However, in newer Firefox versions (and soon, Chrome probably as well) the browser would not follow that redirect anymore. Hence, we need to make sure to only make api requests to the same protocol. Doing API requests from an https hosted fronted to an http hosted api would probably fail already anyway.

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#3303
Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-authored-by: konrad <k@knt.li>
Co-committed-by: konrad <k@knt.li>
2023-03-26 19:18:47 +00:00
renovate cc519e6773 chore(deps): update dependency esbuild to v0.17.14 2023-03-26 03:05:27 +00:00
renovate f9dcae4f65 chore(deps): update dependency @types/node to v18.15.10 2023-03-25 23:05:50 +00:00
50 changed files with 1107 additions and 2136 deletions

View File

@ -0,0 +1,96 @@
# How the file was made: template used: GitHub's suggested "deploy Nuxt app to GitHub pages"
# Since it's a Vite project, better refer to https://vitejs.dev/guide/static-deploy.html#github-pages
name: Deploy and release
on:
push:
tags:
- webxdc*
# branches: ["webxdc-prototype"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
# Need `contents: write` to make a release.
contents: write
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
release-webxdc:
# Only make a release on tags
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
# TODO refactor: steps are duplicated for both jobs. I think
# YAML can help here.
- name: Checkout
uses: actions/checkout@v3
# https://pnpm.io/continuous-integration
- name: Set up pnpm
uses: pnpm/action-setup@v2
with:
version: 7
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: "16"
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm run build-webxdc
- name: Release
uses: softprops/action-gh-release@v1
with:
# prerelease: ${{ contains(github.event.ref, '-beta') }}
prerelease: true
fail_on_unmatched_files: true
files: dist/*.xdc
# Deploy to GitHub Pages.
deploy-gh-pages:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
# https://pnpm.io/continuous-integration
- name: Set up pnpm
uses: pnpm/action-setup@v2
with:
version: 7
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: "16"
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Build
env:
REPO_OWNER_AND_NAME: ${{ github.repository }}
#VIKUNJA_FRONTEND_BASE: /${{ github.repository }}/
# Set VIKUNJA_FRONTEND_BASE to the repo name
run: VIKUNJA_FRONTEND_BASE=$(echo "$REPO_OWNER_AND_NAME" | cut -d / -f 2) pnpm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
path: ./dist
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1

27
create-xdc.sh Executable file
View File

@ -0,0 +1,27 @@
#!/bin/sh
case "$1" in
"-h" | "--help")
echo "usage: ${0##*/} [PACKAGE_NAME]"
exit
;;
"")
PACKAGE_NAME=${PWD##*/} # '##*/' removes everything before the last slash and the last slash
;;
*)
PACKAGE_NAME=${1%.xdc} # '%.xdc' removes the extension and allows PACKAGE_NAME to be given with or without extension
;;
esac
rm "$PACKAGE_NAME.xdc" 2> /dev/null
zip -9 --recurse-paths "$PACKAGE_NAME.xdc" --exclude LICENSE README.md webxdc.js webxdc.d.ts "./*.sh" "./*.xdc" -- *
echo "success, archive contents:"
unzip -l "$PACKAGE_NAME.xdc"
# check package size
MAXSIZE=655360
size=$(wc -c < "$PACKAGE_NAME.xdc")
if [ "$size" -ge $MAXSIZE ]; then
echo "WARNING: package size exceeded the limit ($size > $MAXSIZE)"
fi

7
generate-webxdc-manifest.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/sh
echo -n 'name = "'
# Get the contents of the `<title>` element of `index.html`
grep -oP "(?<=<title>).*?(?=</title>)" index.html | tr -d '\n' \
&& echo '"' \
&& echo -n 'source_code_url = "https://github.com/WofWca/vikunja-frontend"'

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@7.30.3",
"packageManager": "pnpm@7.30.5",
"keywords": [
"todo",
"productivity",
@ -27,6 +27,8 @@
"preview": "vite preview --port 4173",
"preview:dev": "vite preview --outDir dist-dev --mode development --port 4173",
"build": "vite build && workbox copyLibraries dist/",
"build-webxdc": "pnpm run build && pnpm run pack-webxdc",
"pack-webxdc": "./generate-webxdc-manifest.sh > dist/manifest.toml && cd dist && cp images/icons/icon-maskable.png icon.png && ../create-xdc.sh vikunja.xdc",
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
"build:dev": "vite build --mode development --outDir dist-dev/",
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
@ -45,9 +47,9 @@
"story:preview": "histoire preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.3.0",
"@fortawesome/free-regular-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/vue-fontawesome": "3.0.3",
"@github/hotkey": "2.0.1",
"@infectoone/vue-ganttastic": "2.1.4",
@ -105,10 +107,10 @@
"@types/focus-within": "1.0.1",
"@types/lodash.debounce": "4.0.7",
"@types/marked": "4.0.8",
"@types/node": "18.15.9",
"@types/node": "18.15.10",
"@types/postcss-preset-env": "7.7.0",
"@typescript-eslint/eslint-plugin": "5.56.0",
"@typescript-eslint/parser": "5.56.0",
"@typescript-eslint/eslint-plugin": "5.57.0",
"@typescript-eslint/parser": "5.57.0",
"@vitejs/plugin-legacy": "4.0.2",
"@vitejs/plugin-vue": "4.1.0",
"@vue/eslint-config-typescript": "11.0.2",
@ -116,15 +118,15 @@
"@vue/tsconfig": "0.1.3",
"autoprefixer": "10.4.14",
"browserslist": "4.21.5",
"caniuse-lite": "1.0.30001468",
"caniuse-lite": "1.0.30001470",
"csstype": "3.1.1",
"cypress": "12.8.1",
"esbuild": "0.17.13",
"esbuild": "0.17.14",
"eslint": "8.36.0",
"eslint-plugin-vue": "9.10.0",
"happy-dom": "8.9.0",
"histoire": "0.15.9",
"netlify-cli": "13.1.6",
"netlify-cli": "13.2.1",
"postcss": "8.4.21",
"postcss-easing-gradients": "3.0.1",
"postcss-easings": "3.0.1",
@ -136,7 +138,7 @@
"typescript": "5.0.2",
"vite": "4.2.1",
"vite-plugin-inject-preload": "1.3.1",
"vite-plugin-pwa": "0.14.6",
"vite-plugin-pwa": "0.14.7",
"vite-svg-loader": "4.0.0",
"vitest": "0.29.7",
"vue-tsc": "1.2.0",

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,6 @@ import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import Ready from '@/components/misc/ready.vue'
import {setLanguage} from '@/i18n'
import AccountDeleteService from '@/services/accountDelete'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
@ -57,41 +56,6 @@ const authLinkShare = computed(() => authStore.authLinkShare)
const {t} = useI18n({useScope: 'global'})
// setup account deletion verification
const accountDeletionConfirm = computed(() => route.query?.accountDeletionConfirm as (string | undefined))
watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
if (accountDeletionConfirm === undefined) {
return
}
const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(accountDeletionConfirm)
success({message: t('user.deletion.confirmSuccess')})
authStore.refreshUserInfo()
}, { immediate: true })
// setup password reset redirect
const userPasswordReset = computed(() => route.query?.userPasswordReset as (string | undefined))
watch(userPasswordReset, (userPasswordReset) => {
if (userPasswordReset === undefined) {
return
}
localStorage.setItem('passwordResetToken', userPasswordReset)
router.push({name: 'user.password-reset.reset'})
}, { immediate: true })
// setup email verification redirect
const userEmailConfirm = computed(() => route.query?.userEmailConfirm as (string | undefined))
watch(userEmailConfirm, (userEmailConfirm) => {
if (userEmailConfirm === undefined) {
return
}
localStorage.setItem('emailConfirmToken', userEmailConfirm)
router.push({name: 'user.login'})
}, { immediate: true })
setLanguage()
useColorScheme()
</script>

View File

@ -5,32 +5,13 @@
<Logo width="164" height="48" />
</router-link>
<MenuButton class="menu-button" />
<div v-if="currentProject.id" class="project-title-wrapper">
<h1 class="project-title">{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
</h1>
<BaseButton :to="{ name: 'project.info', params: { projectId: currentProject.id } }" class="project-title-button">
<icon icon="circle-info" />
</BaseButton>
<project-settings-dropdown v-if="canWriteCurrentProject && currentProject.id !== -1"
class="project-title-dropdown" :project="currentProject">
<template #trigger="{ toggleOpen }">
<BaseButton class="project-title-button" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon" />
</BaseButton>
</template>
</project-settings-dropdown>
</div>
<!-- <MenuButton class="menu-button" /> -->
<div class="navbar-end">
<BaseButton @click="openQuickActions" class="trigger-button" v-shortcut="'Control+k'"
:title="$t('keyboardShortcuts.quickSearch')">
<icon icon="search" />
</BaseButton>
<Notifications />
<dropdown>
<template #trigger="{ toggleOpen, open }">
<BaseButton class="username-dropdown-trigger" @click="toggleOpen" variant="secondary" :shadow="false">
@ -59,9 +40,6 @@
<dropdown-item :to="{ name: 'about' }">
{{ $t('about.title') }}
</dropdown-item>
<dropdown-item @click="authStore.logout()">
{{ $t('user.auth.logout') }}
</dropdown-item>
</dropdown>
</div>
</header>

View File

@ -16,6 +16,7 @@
:class="{'is-visible': background}"
class="app-container-background background-fade-in d-print-none"
:style="{'background-image': background && `url(${background})`}"></div>
<!-- Can't remove <navigation> because otherwise namespaces would not get loaded. -->
<navigation class="d-print-none"/>
<main
class="app-content"
@ -71,7 +72,6 @@ import {useBaseStore} from '@/stores/base'
import {useLabelStore} from '@/stores/labels'
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
@ -112,8 +112,6 @@ watch(() => route.name as string, (routeName) => {
// TODO: Reset the title if the page component does not set one itself
useRenewTokenOnFocus()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
</script>

View File

@ -63,7 +63,7 @@ const route = useRoute()
const baseStore = useBaseStore()
const ready = computed(() => baseStore.ready)
const online = useOnline()
const online = true
const error = ref('')
const showLoading = computed(() => !ready.value && error.value === '')

View File

@ -80,9 +80,9 @@ const userInfo = computed(() => authStore.info)
let interval: ReturnType<typeof setInterval>
onMounted(() => {
loadNotifications()
// loadNotifications()
document.addEventListener('click', hidePopup)
interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
// interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
})
onUnmounted(() => {

View File

@ -45,7 +45,7 @@ export function useMenuActive() {
}
return {
menuActive: readonly(menuActive),
menuActive: false,
setMenuActive,
toggleMenu,
}

View File

@ -1,46 +0,0 @@
import {computed} from 'vue'
import {useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
import {useAuthStore} from '@/stores/auth'
import {MILLISECONDS_A_SECOND, SECONDS_A_HOUR} from '@/constants/date'
const SECONDS_TOKEN_VALID = 60 * SECONDS_A_HOUR
export function useRenewTokenOnFocus() {
const router = useRouter()
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
const authenticated = computed(() => authStore.authenticated)
// Try renewing the token every time vikunja is loaded initially
// (When opening the browser the focus event is not fired)
authStore.renewToken()
// Check if the token is still valid if the window gets focus again to maybe renew it
useEventListener('focus', async () => {
if (!authenticated.value) {
return
}
const nowInSeconds = new Date().getTime() / MILLISECONDS_A_SECOND
const expiresIn = userInfo.value !== null
? userInfo.value.exp - nowInSeconds
: 0
// If the token expiry is negative, it is already expired and we have no choice but to redirect
// the user to the login page
if (expiresIn <= 0) {
await authStore.checkAuth()
await router.push({name: 'user.login'})
return
}
// Check if the token is valid for less than 60 hours and renew if thats the case
if (expiresIn < SECONDS_TOKEN_VALID) {
authStore.renewToken()
console.debug('renewed token')
}
})
}

View File

@ -60,28 +60,13 @@ const SORT_BY_DEFAULT: SortBy = {
/**
* This mixin provides a base set of methods and properties to get tasks.
*/
export function useTaskList(projectId, sortByDefault: SortBy = SORT_BY_DEFAULT) {
const params = ref({...getDefaultParams()})
const search = ref('')
export function useTaskList(projectId) {
const page = ref(1)
const sortBy = ref({ ...sortByDefault })
const getAllTasksParams = computed(() => {
let loadParams = {...params.value}
if (search.value !== '') {
loadParams.s = search.value
}
loadParams = formatSortOrder(sortBy.value, loadParams)
return [
{projectId: projectId.value},
loadParams,
// TODO_OFFLINE still need sorting by position.
{},
page.value || 1,
]
})
@ -103,10 +88,7 @@ export function useTaskList(projectId, sortByDefault: SortBy = SORT_BY_DEFAULT)
const route = useRoute()
watch(() => route.query, (query) => {
const { page: pageQueryValue, search: searchQuery } = query
if (searchQuery !== undefined) {
search.value = searchQuery as string
}
const { page: pageQueryValue } = query
if (pageQueryValue !== undefined) {
page.value = Number(pageQueryValue)
}
@ -129,8 +111,5 @@ export function useTaskList(projectId, sortByDefault: SortBy = SORT_BY_DEFAULT)
totalPages,
currentPage: page,
loadTasks,
searchTerm: search,
params,
sortByParam: sortBy,
}
}

View File

@ -5,17 +5,18 @@ const API_DEFAULT_PORT = '3456'
export const ERROR_NO_API_URL = 'noApiUrlProvided'
// TODO_OFFLINE remove?
export const checkAndSetApiUrl = (url: string): Promise<string> => {
if(url.startsWith('/')) {
if (url.startsWith('/')) {
url = window.location.host + url
}
// Check if the url has an http prefix
// Check if the url has a http prefix
if (
!url.startsWith('http://') &&
!url.startsWith('https://')
) {
url = `http://${url}`
url = `${window.location.protocol}//${url}`
}
const urlToCheck: URL = new URL(url)
@ -41,15 +42,6 @@ export const checkAndSetApiUrl = (url: string): Promise<string> => {
}
throw e
})
.catch(e => {
// Check if it has a port and if not check if it is reachable at https
if (urlToCheck.protocol === 'http:') {
urlToCheck.protocol = 'https:'
window.API_URL = urlToCheck.toString()
return updateConfig()
}
throw e
})
.catch(e => {
// Check if it is reachable at /api/v1 and https
urlToCheck.pathname = origUrlToCheck.pathname
@ -66,7 +58,6 @@ export const checkAndSetApiUrl = (url: string): Promise<string> => {
.catch(e => {
// Check if it is reachable at port API_DEFAULT_PORT and https
if (urlToCheck.port !== API_DEFAULT_PORT) {
urlToCheck.protocol = 'https:'
urlToCheck.port = API_DEFAULT_PORT
window.API_URL = urlToCheck.toString()
return updateConfig()
@ -74,30 +65,7 @@ export const checkAndSetApiUrl = (url: string): Promise<string> => {
throw e
})
.catch(e => {
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1 and https
urlToCheck.pathname = origUrlToCheck.pathname
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
!urlToCheck.pathname.endsWith('/api/v1/')
) {
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
window.API_URL = urlToCheck.toString()
return updateConfig()
}
throw e
})
.catch(e => {
// Check if it is reachable at port API_DEFAULT_PORT and http
if (urlToCheck.port !== API_DEFAULT_PORT) {
urlToCheck.protocol = 'http:'
urlToCheck.port = API_DEFAULT_PORT
window.API_URL = urlToCheck.toString()
return updateConfig()
}
throw e
})
.catch(e => {
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1 and http
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1
urlToCheck.pathname = origUrlToCheck.pathname
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
@ -118,7 +86,7 @@ export const checkAndSetApiUrl = (url: string): Promise<string> => {
localStorage.setItem('API_URL', window.API_URL)
return window.API_URL
}
throw new Error(ERROR_NO_API_URL)
})
}

View File

@ -15,7 +15,7 @@ export const SUPPORTED_LOCALES = {
'pt-PT': 'Português',
'zh-CN': 'Chinese',
'no-NO': 'Norsk Bokmål',
} as Record<string, string>
} as const
export type SupportedLocale = keyof typeof SUPPORTED_LOCALES
@ -23,12 +23,12 @@ export const DEFAULT_LANGUAGE: SupportedLocale= 'en'
export type ISOLanguage = string
// we load all messsages async
// we load all messages async
export const i18n = createI18n({
fallbackLocale: DEFAULT_LANGUAGE,
legacy: false,
messages: {
en: langEN,
[DEFAULT_LANGUAGE]: langEN,
} as Record<SupportedLocale, any>,
})
@ -54,16 +54,16 @@ export async function setLanguage(lang: SupportedLocale = getCurrentLanguage()):
}
export function getCurrentLanguage(): SupportedLocale {
const savedLanguage = localStorage.getItem('language')
const savedLanguage = localStorage.getItem('language') as SupportedLocale | null
if (savedLanguage !== null) {
return savedLanguage
}
const browserLanguage = navigator.language
const language: SupportedLocale | undefined = Object.keys(SUPPORTED_LOCALES).find(langKey => {
const language = Object.keys(SUPPORTED_LOCALES).find(langKey => {
return langKey === browserLanguage || langKey.startsWith(browserLanguage + '-')
})
}) as SupportedLocale | undefined
return language || DEFAULT_LANGUAGE
}

182
src/localBackend/buckets.ts Normal file
View File

@ -0,0 +1,182 @@
import type { IBucket } from '@/modelTypes/IBucket'
import { getAllTasks, getTasksOfBucket } from './tasks'
import { defaultPositionIfZero } from './utils/calculateDefaultPosition'
// TODO_OFFLINE there is a lot of duplication between `localBackend` parts.
type IBucketWithoutTasks = Omit<IBucket, 'tasks'>
// Be carefult not to convert it to `const initialBuckets =` since we need to return a new
// array each time to avoid getting it mutated.
// The actual backend actually only creates one bucket by default.
function getInitialBuckets(): IBucketWithoutTasks[] {
return [
{
id: 1,
title: 'Backlog',
projectId: 1,
limit: 0,
isDoneBucket: false,
position: 65536,
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-16T11:55:59+01:00',
},
},
{
id: 2,
title: 'In progress',
projectId: 1,
limit: 0,
isDoneBucket: false,
position: 131072,
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-16T11:55:59+01:00',
},
},
{
id: 3,
title: 'Done',
projectId: 1,
limit: 0,
isDoneBucket: true,
position: 262144,
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-16T11:55:59+01:00',
},
},
]
}
function getAllBucketsWithoutTasks(): IBucketWithoutTasks[] {
const fromStorage = localStorage.getItem('buckets')
if (!fromStorage) {
// TODO_OFFLINE dynamic import.
// Currently we have a constant list. Each project must have at least one bucket
return getInitialBuckets()
}
// TODO_OFFLINE fill the `tasks`.
return JSON.parse(fromStorage)
}
// /**
// * Mutates `bucket` and returns it.
// */
// function fillBucketTasks(bucket: IBucketWithoutTasks): IBucket {
// (bucket as IBucket).tasks = getAllTas
// }
function getAllBuckets(): IBucket[] {
const bucketsWithoutTasks = getAllBucketsWithoutTasks()
const buckets = bucketsWithoutTasks.map(bucketWithoutTasks => {
const b = bucketWithoutTasks as IBucket
b.tasks = getTasksOfBucket(b.id)
// Tasks are always sorted by their `kanbanPosition`.
// https://kolaente.dev/vikunja/api/src/commit/6d8db0ce1e00e8c200a43b28ac98eb0fb825f4d4/pkg/models/kanban.go#L173-L178
.sort((a, b) => a.kanbanPosition - b.kanbanPosition)
return b
})
return buckets
}
export function getAllBucketsOfProject(projectId: number): IBucket[] {
// TODO_OFFLINE filter by position bruh.
// TODO_OFFLINE perf: maybe it's not worth getting tasks of each project then in `getAllBuckets`.
return getAllBuckets()
.filter(b => b.projectId === projectId)
// Buckets are always sorted.
// https://kolaente.dev/vikunja/api/src/commit/6d8db0ce1e00e8c200a43b28ac98eb0fb825f4d4/pkg/models/kanban.go#L139-L143
.sort((a, b) => a.position - b.position)
}
/** https://kolaente.dev/vikunja/api/src/commit/769db0dab2e50bc477dec6c7e18309effc80a1bd/pkg/models/kanban.go#L80-L87 */
export function getDefaultBucket(projectId: number): IBucket {
return getAllBucketsOfProject(projectId).sort((a, b) => a.position - b.position)[0]
}
/**
* https://kolaente.dev/vikunja/api/src/commit/066c26f83e1e066bdc9d80b4642db1df0d6a77eb/pkg/models/kanban.go#L252-L267
*/
export function createBucket(bucket: IBucket): IBucket {
const allBuckets = getAllBucketsWithoutTasks()
const maxPosition = allBuckets.reduce((currMax, b) => {
return b.position > currMax
? b.position
: currMax
}, -Infinity)
const newBucketFullData = {
...bucket,
id: Math.round(Math.random() * 1000000000000),
// position: defaultPositionIfZero(bucket.position),
position: maxPosition * 2,
}
const newBucketFullDataToStore = {
...newBucketFullData,
// It's not actually necessary FYI, it will just taske extra space in the storage.
tasks: undefined,
}
allBuckets.push(newBucketFullDataToStore)
localStorage.setItem('buckets', JSON.stringify(allBuckets))
return newBucketFullData
}
export function updateBucket(newBucketData: IBucket) {
const allBuckets = getAllBucketsWithoutTasks()
// TODO_OFFLINE looks like the real backend also filters by prjectId, but
// since in localBackend all bucket `id`s are unique even between projects,
// it's not necessary
const targetBucketInd = allBuckets.findIndex(b => b.id === newBucketData.id)
if (targetBucketInd < 0) {
console.warn('Tried to update a bucket, but it does not exist')
return
}
// TODO_OFFLINE remove tasks.
allBuckets.splice(targetBucketInd, 1, newBucketData)
localStorage.setItem('buckets', JSON.stringify(allBuckets))
return newBucketData
}
export function deleteBucket({ id }: { id: number }) {
const allBuckets = getAllBucketsWithoutTasks()
if (allBuckets.length <= 1) {
// Prevent removing the last bucket.
// https://kolaente.dev/vikunja/api/src/commit/6aadaaaffc1fff4a94e35e8fa3f6eab397cbc3ce/pkg/models/kanban.go#L325-L335
return
}
const targetBucketInd = allBuckets.findIndex(b => b.id === id)
if (targetBucketInd < 0) {
console.warn('Tried to delete a bucket, but it does not exist')
return
}
const projectId = allBuckets[targetBucketInd].projectId
allBuckets.splice(targetBucketInd, 1)
localStorage.setItem('buckets', JSON.stringify(allBuckets))
// Move all the tasks from this bucket to the default one.
// https://kolaente.dev/vikunja/api/src/commit/6aadaaaffc1fff4a94e35e8fa3f6eab397cbc3ce/pkg/models/kanban.go#L349-L353
const deletedBuckedId = id
const defaultBucketId = getDefaultBucket(projectId).id
const allTasks = getAllTasks()
allTasks.forEach(t => {
if (t.bucketId === deletedBuckedId) {
t.bucketId = defaultBucketId
}
})
localStorage.setItem('tasks', JSON.stringify(allTasks))
// TODO_OFFLINE idk what it's supposed to return
return true
}

105
src/localBackend/tasks.ts Normal file
View File

@ -0,0 +1,105 @@
import type { ITask } from '@/modelTypes/ITask'
import { getDefaultBucket } from './buckets'
import { defaultPositionIfZero } from './utils/calculateDefaultPosition'
// TODO_OFFLINE return types? ITask is not it, because of snake case and Date format.
// TODO_OFFLINE actually `project_id` is not always present on a task.
// https://kolaente.dev/vikunja/frontend/src/commit/0ff0d8c5b89bd6a8b628ddbe6074f61797b6b9c1/src/modelTypes/ITask.ts#L52
// So getTaskOfProject doesn't work right?? Should we make it return all tasks
// (since we only support just one project for now?)
// Actually `project_id` is present on a task when it is created:
// https://kolaente.dev/vikunja/frontend/src/commit/6aa02e29b19f9f57620bdf09919df34c363e1f3d/src/services/abstractService.ts#L404
// Here it substitutes `{projectId}` in the URL.
let _nextUniqueIntToReturn = 1
/**
* @returns A unique (in this browsing context) integer
*/
function getUniquieInt() {
return _nextUniqueIntToReturn++
}
/**
* Yes, we store the data in camelCase.
*/
export function getAllTasks(): ITask[] {
const fromStorage = localStorage.getItem('tasks')
if (!fromStorage) {
return []
}
const tasks: ITask[] = JSON.parse(fromStorage)
// TODO_OFFLINE don't just always sort them by position but look at
// `parameters.sort_by`.
return tasks.sort((a, b) => a.position - b.position)
}
// getAllTasksWithFilters(params)
// TODO_OFFLINE we only have one project currently, actually.
export function getTasksOfProject<PID extends number>(projectId: PID): Array<ITask & { projectId: PID }> {
const tasks: ITask[] = getAllTasks().filter(t => t.projectId === projectId)
return tasks
}
/**
* Unsorted
*/
export function getTasksOfBucket<BID extends number>(
bucketId: BID,
): Array<ITask & { bucketId: BID }> {
const tasks: ITask[] = getAllTasks().filter(t => t.bucketId === bucketId)
return tasks
}
/**
* https://kolaente.dev/vikunja/api/src/commit/066c26f83e1e066bdc9d80b4642db1df0d6a77eb/pkg/models/tasks.go#L913-L990
*/
export function createTask(newTask: ITask): ITask {
const allTasks = getAllTasks()
const newTaskFullData: ITask = {
...newTask,
id: Math.round(Math.random() * 1000000000000),
// TODO_OFFLINE created_by, indentifier, index
position: defaultPositionIfZero(newTask.position),
kanbanPosition: defaultPositionIfZero(newTask.kanbanPosition),
// https://kolaente.dev/vikunja/api/src/commit/769db0dab2e50bc477dec6c7e18309effc80a1bd/pkg/models/tasks.go#L939-L940
bucketId: newTask.bucketId || getDefaultBucket(newTask.projectId).id,
}
allTasks.unshift(newTaskFullData)
localStorage.setItem('tasks', JSON.stringify(allTasks))
return newTaskFullData
}
export function getTask(taskId: number) {
return getAllTasks().find(t => t.id === taskId)
}
export function updateTask(newTaskData: ITask) {
// TODO_OFFLINE a lot of stuff is not implemented. For example, marking a task "done"
// when it is moved to the "done" bucket.
// https://kolaente.dev/vikunja/api/src/commit/6aadaaaffc1fff4a94e35e8fa3f6eab397cbc3ce/pkg/models/tasks.go#L1008
const allTasks = getAllTasks()
const targetTaskInd = allTasks.findIndex(t => t.id === newTaskData.id)
if (targetTaskInd < 0) {
console.warn('Tried to update a task, but it does not exist')
return
}
allTasks.splice(targetTaskInd, 1, newTaskData)
localStorage.setItem('tasks', JSON.stringify(allTasks))
return newTaskData
}
export function deleteTask({ id }: { id: number }) {
const allTasks = getAllTasks()
const targetTaskInd = allTasks.findIndex(t => t.id === id)
if (targetTaskInd < 0) {
console.warn('Tried to delete a task, but it does not exist')
return
}
allTasks.splice(targetTaskInd, 1)
localStorage.setItem('tasks', JSON.stringify(allTasks))
// TODO_OFFLINE idk what it's supposed to return
return true
}

View File

@ -0,0 +1,12 @@
// https://kolaente.dev/vikunja/api/src/commit/066c26f83e1e066bdc9d80b4642db1df0d6a77eb/pkg/models/tasks.go#L874-L880
// TODO_OFFLINE this is wrong. For example, for buckets. it is possible to create several buckets
// with position: 65535, which would mess up positioning. Looks like we actually need to consider
// position. How about store `lastUsedGlobalId` in `localStorage` and simply increment it each
// time an entity is created?
export function defaultPositionIfZero(/* entityID: number, */ position: number): number {
if (position === 0) {
return /* entityID * */ 2**16
}
return position
}

View File

@ -1,4 +1,4 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import type { RouteLocation } from 'vue-router'
import {saveLastVisited} from '@/helpers/saveLastVisited'
@ -15,9 +15,6 @@ import HomeComponent from '@/views/Home.vue'
import NotFoundComponent from '@/views/404.vue'
const About = () => import('@/views/About.vue')
// User Handling
import LoginComponent from '@/views/user/Login.vue'
import RegisterComponent from '@/views/user/Register.vue'
import OpenIdAuth from '@/views/user/OpenIdAuth.vue'
const DataExportDownload = () => import('@/views/user/DataExportDownload.vue')
// Tasks
import UpcomingTasksComponent from '@/views/tasks/ShowTasks.vue'
@ -41,12 +38,6 @@ const ProjectKanban = () => import('@/views/project/ProjectKanban.vue')
const ProjectInfo = () => import('@/views/project/ProjectInfo.vue')
// Project Settings
const ProjectSettingEdit = () => import('@/views/project/settings/edit.vue')
const ProjectSettingBackground = () => import('@/views/project/settings/background.vue')
const ProjectSettingDuplicate = () => import('@/views/project/settings/duplicate.vue')
const ProjectSettingShare = () => import('@/views/project/settings/share.vue')
const ProjectSettingDelete = () => import('@/views/project/settings/delete.vue')
const ProjectSettingArchive = () => import('@/views/project/settings/archive.vue')
// Namespace Settings
const NamespaceSettingEdit = () => import('@/views/namespaces/settings/edit.vue')
@ -55,21 +46,12 @@ const NamespaceSettingArchive = () => import('@/views/namespaces/settings/archiv
const NamespaceSettingDelete = () => import('@/views/namespaces/settings/delete.vue')
// Saved Filters
const FilterNew = () => import('@/views/filters/FilterNew.vue')
const FilterEdit = () => import('@/views/filters/FilterEdit.vue')
const FilterDelete = () => import('@/views/filters/FilterDelete.vue')
const PasswordResetComponent = () => import('@/views/user/PasswordReset.vue')
const GetPasswordResetComponent = () => import('@/views/user/RequestPasswordReset.vue')
const UserSettingsComponent = () => import('@/views/user/Settings.vue')
const UserSettingsAvatarComponent = () => import('@/views/user/settings/Avatar.vue')
const UserSettingsCaldavComponent = () => import('@/views/user/settings/Caldav.vue')
const UserSettingsDataExportComponent = () => import('@/views/user/settings/DataExport.vue')
const UserSettingsDeletionComponent = () => import('@/views/user/settings/Deletion.vue')
const UserSettingsEmailUpdateComponent = () => import('@/views/user/settings/EmailUpdate.vue')
const UserSettingsGeneralComponent = () => import('@/views/user/settings/General.vue')
const UserSettingsPasswordUpdateComponent = () => import('@/views/user/settings/PasswordUpdate.vue')
const UserSettingsTOTPComponent = () => import('@/views/user/settings/TOTP.vue')
// Project Handling
const NewProjectComponent = () => import('@/views/project/NewProject.vue')
@ -81,7 +63,10 @@ const EditTeamComponent = () => import('@/views/teams/EditTeam.vue')
const NewTeamComponent = () => import('@/views/teams/NewTeam.vue')
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
// Using `createWebHashHistory` instead of `createWebHistory` so it can
// properly work on GitHub pages and such.
// TODO_OFFLINE make it configurable, make MR to the upstream repo.
history: createWebHashHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from, savedPosition) {
// If the user is using their forward/backward keys to navigate, we want to restore the scroll view
if (savedPosition) {
@ -96,11 +81,17 @@ const router = createRouter({
// Otherwise just scroll to the top
return {left: 0, top: 0}
},
// TODO_OFFLINE remove the references of the removed routes.
routes: [
{
path: '/',
name: 'home',
component: HomeComponent,
// TODO_OFFLINE don't redirect when there's actually something useful on the home page
redirect: {
name: 'project.index',
params: { projectId: 1 },
},
},
{
path: '/:pathMatch(.*)*',
@ -113,38 +104,6 @@ const router = createRouter({
name: 'bad-not-found',
component: NotFoundComponent,
},
{
path: '/login',
name: 'user.login',
component: LoginComponent,
meta: {
title: 'user.auth.login',
},
},
{
path: '/get-password-reset',
name: 'user.password-reset.request',
component: GetPasswordResetComponent,
meta: {
title: 'user.auth.resetPassword',
},
},
{
path: '/password-reset',
name: 'user.password-reset.reset',
component: PasswordResetComponent,
meta: {
title: 'user.auth.resetPassword',
},
},
{
path: '/register',
name: 'user.register',
component: RegisterComponent,
meta: {
title: 'user.auth.createAccount',
},
},
{
path: '/user/settings',
name: 'user.settings',
@ -166,31 +125,11 @@ const router = createRouter({
name: 'user.settings.data-export',
component: UserSettingsDataExportComponent,
},
{
path: '/user/settings/deletion',
name: 'user.settings.deletion',
component: UserSettingsDeletionComponent,
},
{
path: '/user/settings/email-update',
name: 'user.settings.email-update',
component: UserSettingsEmailUpdateComponent,
},
{
path: '/user/settings/general',
name: 'user.settings.general',
component: UserSettingsGeneralComponent,
},
{
path: '/user/settings/password-update',
name: 'user.settings.password-update',
component: UserSettingsPasswordUpdateComponent,
},
{
path: '/user/settings/totp',
name: 'user.settings.totp',
component: UserSettingsTOTPComponent,
},
],
},
{
@ -289,73 +228,6 @@ const router = createRouter({
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/edit',
name: 'project.settings.edit',
component: ProjectSettingEdit,
props: route => ({ projectId: Number(route.params.projectId as string) }),
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/background',
name: 'project.settings.background',
component: ProjectSettingBackground,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/duplicate',
name: 'project.settings.duplicate',
component: ProjectSettingDuplicate,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/share',
name: 'project.settings.share',
component: ProjectSettingShare,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/delete',
name: 'project.settings.delete',
component: ProjectSettingDelete,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/archive',
name: 'project.settings.archive',
component: ProjectSettingArchive,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/edit',
name: 'filter.settings.edit',
component: FilterEdit,
meta: {
showAsModal: true,
},
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/projects/:projectId/settings/delete',
name: 'filter.settings.delete',
component: FilterDelete,
meta: {
showAsModal: true,
},
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/projects/:projectId/info',
name: 'project.info',
@ -464,19 +336,6 @@ const router = createRouter({
code: route.query.code as string,
}),
},
{
path: '/filters/new',
name: 'filters.create',
component: FilterNew,
meta: {
showAsModal: true,
},
},
{
path: '/auth/openid/:provider',
name: 'openid.auth',
component: OpenIdAuth,
},
{
path: '/about',
name: 'about',
@ -485,6 +344,7 @@ const router = createRouter({
],
})
// TODO_OFFLINE Remove this function?
export async function getAuthForRoute(route: RouteLocation) {
const authStore = useAuthStore()
if (authStore.authUser || authStore.authLinkShare) {

View File

@ -4,7 +4,7 @@ import type {Method} from 'axios'
import {objectToSnakeCase} from '@/helpers/case'
import AbstractModel from '@/models/abstractModel'
import type {IAbstract} from '@/modelTypes/IAbstract'
import type {Right} from '@/constants/rights'
import {RIGHTS} from '@/constants/rights'
interface Paths {
create : string
@ -268,12 +268,34 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* @param params Optional query parameters
*/
get(model : Model, params = {}) {
if (this._get) {
return this.get_Offline(...arguments);
}
if (this.paths.get === '') {
throw new Error('This model is not able to get data.')
}
return this.getM(this.paths.get, model, params)
}
async get_Offline(model : Model, params = {}) {
const cancel = this.setLoading()
model = this.beforeGet(model)
try {
// TODO_OFFLINE `_get` should throw if it can't properly handle all the arguments
// so that I can more easily find unsupported usages in the code.
const modelInitObj = await this._get(...arguments);
const result = this.modelGetFactory(modelInitObj)
// TODO_OFFLINE what does this do?
result.maxRight = RIGHTS.READ_WRITE
return result
} finally {
cancel()
}
}
abstract _get?: (model : Model, params) => PromiseLike<Model> | Model
/**
* This is a more abstract implementation which only does a get request.
@ -313,6 +335,12 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* @param page The page to get
*/
async getAll(model : Model = new AbstractModel({}), params = {}, page = 1): Promise<Model[]> {
// TODO_OFFLINE this is a condition for debugging getAll_Offline will replace `getALl`
// when we're done defining `abstract _getAll`.
if (this._getAll) {
return this.getAll_Offline(...arguments)
}
if (this.paths.getAll === '') {
throw new Error('This model is not able to get data.')
}
@ -337,12 +365,45 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
cancel()
}
}
// TODO_OFFLINE warn if arguments were provided such that we can find the places
// where we're supposed to depend on them.
async getAll_Offline(model : Model = new AbstractModel({}), params = {}, page = 1): Promise<Model[]> {
// params.page = page
const cancel = this.setLoading()
// model = this.beforeGet(model)
// const finalUrl = this.getReplacedRoute(this.paths.getAll, model)
try {
// const response = await this.http.get(finalUrl, {params: prepareParams(params)})
// this.resultCount = Number(response.headers['x-pagination-result-count'])
// this.totalPages = Number(response.headers['x-pagination-total-pages'])
const modelInitObjects = await this._getAll(...arguments)
this.totalPages = 1 // TODO_OFFLINE?
this.resultCount = modelInitObjects.length
return modelInitObjects.map(entry => this.modelGetAllFactory(entry))
} finally {
cancel()
}
}
// TODO_OFFLINE what if service doesn't implements `getAll`? Need some composition I guess,
// instead of just throwing.
// Also a better name?
// abstract _getAll(): PromiseLike<Model[]> | Model[]
abstract _getAll?: () => PromiseLike<Model[]> | Model[]
/**
* Performs a put request to the url specified before
* @returns {Promise<any | never>}
*/
async create(model : Model) {
if (this._create) {
return this.create_Offline(...arguments)
}
if (this.paths.create === '') {
throw new Error('This model is not able to create data.')
}
@ -361,6 +422,30 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
cancel()
}
}
async create_Offline(model : Model) {
const cancel = this.setLoading()
try {
// As in http interceptors above.
// For interaction with a real backend we'd convert to snake case here,
// but locally we can store everything in the camel case format, which is used
// everywhere in the app, unlike snake case.
const toSend = this.beforeCreate(model)
const modelInitObj = await this._create(toSend)
const resultModel = this.modelCreateFactory(modelInitObj)
// This doesn't actually do anything currently, I think, since we're just copying
// whatever was passed to `localStorage`
if (typeof model.maxRight !== 'undefined') {
resultModel.maxRight = model.maxRight
}
return resultModel
} finally {
cancel()
}
}
abstract _create(model: Model)
/**
* An abstract implementation to send post requests.
@ -385,6 +470,10 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* Performs a post request to the update url
*/
update(model : Model) {
if (this._update) {
return this.update_Offline(...arguments)
}
if (this.paths.update === '') {
throw new Error('This model is not able to update data.')
}
@ -392,11 +481,34 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
const finalUrl = this.getReplacedRoute(this.paths.update, model)
return this.post(finalUrl, model)
}
async update_Offline(model: Model) {
const cancel = this.setLoading()
try {
const toSend = this.beforeUpdate(model)
const modelInitObj = await this._update(toSend)
const newModel = this.modelUpdateFactory(modelInitObj)
if (typeof model.maxRight !== 'undefined') {
newModel.maxRight = model.maxRight
}
return newModel
} finally {
cancel()
}
}
abstract _update(model: unknown)
/**
* Performs a delete request to the update url
*/
async delete(model : Model) {
if (this._delete) {
return this.delete_Offline(...arguments)
}
if (this.paths.delete === '') {
throw new Error('This model is not able to delete data.')
}
@ -411,6 +523,24 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
cancel()
}
}
async delete_Offline(model: Model) {
if (this.paths.delete === '') {
throw new Error('This model is not able to delete data.')
}
const cancel = this.setLoading()
try {
const toSend = this.beforeDelete(model)
const data = await this._delete(toSend)
// TODO_OFFLINE what is it supposed to return??
return data
} finally {
cancel()
}
}
abstract _delete(model: unknown)
/**
* Uploads a file to a url.

View File

@ -1,15 +0,0 @@
import AbstractService from './abstractService'
export default class AccountDeleteService extends AbstractService {
request(password: string) {
return this.post('/user/deletion/request', {password})
}
confirm(token: string) {
return this.post('/user/deletion/confirm', {token})
}
cancel(password: string) {
return this.post('/user/deletion/cancel', {password})
}
}

View File

@ -1,30 +0,0 @@
import AbstractService from './abstractService'
import BackgroundImageModel from '../models/backgroundImage'
import ProjectModel from '@/models/project'
import type { IBackgroundImage } from '@/modelTypes/IBackgroundImage'
export default class BackgroundUnsplashService extends AbstractService<IBackgroundImage> {
constructor() {
super({
getAll: '/backgrounds/unsplash/search',
update: '/projects/{projectId}/backgrounds/unsplash',
})
}
modelFactory(data: Partial<IBackgroundImage>) {
return new BackgroundImageModel(data)
}
modelUpdateFactory(data) {
return new ProjectModel(data)
}
async thumb(model) {
const response = await this.http({
url: `/backgrounds/unsplash/images/${model.id}/thumb`,
method: 'GET',
responseType: 'blob',
})
return window.URL.createObjectURL(new Blob([response.data]))
}
}

View File

@ -2,6 +2,7 @@ import AbstractService from './abstractService'
import BucketModel from '../models/bucket'
import TaskService from '@/services/task'
import type { IBucket } from '@/modelTypes/IBucket'
import { createBucket, deleteBucket, getAllBucketsOfProject, updateBucket } from '@/localBackend/buckets'
export default class BucketService extends AbstractService<IBucket> {
constructor() {
@ -13,6 +14,11 @@ export default class BucketService extends AbstractService<IBucket> {
})
}
_getAll = ({ projectId }: { projectId: number }) => getAllBucketsOfProject(projectId)
_create = (bucket: IBucket) => createBucket(bucket)
_update = (bucket: IBucket) => updateBucket(bucket)
_delete = (bucket: IBucket) => deleteBucket(bucket)
modelFactory(data: Partial<IBucket>) {
return new BucketModel(data)
}

View File

@ -1,9 +0,0 @@
import AbstractService from './abstractService'
export default class EmailUpdateService extends AbstractService {
constructor() {
super({
update: '/user/settings/email',
})
}
}

View File

@ -3,17 +3,71 @@ import LabelModel from '@/models/label'
import type {ILabel} from '@/modelTypes/ILabel'
import {colorFromHex} from '@/helpers/color/colorFromHex'
function getAllLabels() {
return [
{
id: 1,
title: 'label',
description: '',
hexColor: 'e8e8e8',
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-06T14:10:00+01:00',
},
created: '2021-05-30T10:45:46+02:00',
updated: '2021-05-30T10:45:46+02:00',
},
{
id: 2,
title: 'abc',
description: '',
hexColor: 'e8e8e8',
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-06T14:10:00+01:00',
},
created: '2023-03-06T10:37:31+01:00',
updated: '2023-03-06T10:37:31+01:00',
},
{
id: 5,
title: 'aa',
description: '',
hexColor: 'e8e8e8',
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-06T14:10:00+01:00',
},
created: '2023-03-06T14:11:53+01:00',
updated: '2023-03-06T14:11:53+01:00',
},
]
}
export default class LabelService extends AbstractService<ILabel> {
constructor() {
super({
create: '/labels',
// create: '/labels',
getAll: '/labels',
get: '/labels/{id}',
update: '/labels/{id}',
delete: '/labels/{id}',
// get: '/labels/{id}',
// update: '/labels/{id}',
// delete: '/labels/{id}',
})
}
_getAll = () => {
return getAllLabels()
}
processModel(label) {
label.created = new Date(label.created).toISOString()
label.updated = new Date(label.updated).toISOString()

View File

@ -14,6 +14,55 @@ export default class NamespaceService extends AbstractService<INamespace> {
})
}
_getAll: () => INamespace[] = () => {
// TODO_OFFLINE
return [
{
id: 1,
title: 'demo',
description: 'demo\'s namespace.',
hexColor: '',
isArchived: false,
owner: {
id: 1,
name: '',
username: 'demo',
email: 'demo@vikunja.io',
created: '0001-01-01T00:00:00Z',
updated: '0001-01-01T00:00:00Z',
},
created: '2021-05-30T10:45:25+02:00',
updated: '2021-05-30T10:45:25+02:00',
// TODO_OFFLINE we also define the same project in the `projects` service
projects: [
{
id: 1,
title: 'Project',
description: '',
identifier: '',
hexColor: '',
namespaceId: 1,
owner: {
id: 1,
name: '',
username: 'demo',
email: 'demo@vikunja.io',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-06T06:59:06+01:00',
},
isArchived: false,
background_information: null,
background_blur_hash: '',
is_favorite: false,
position: 65536,
created: '2021-05-30T10:45:30+02:00',
updated: '2023-03-06T09:24:43+01:00',
},
],
},
]
}
modelFactory(data) {
return new NamespaceModel(data)
}

View File

@ -1,38 +0,0 @@
import AbstractService from './abstractService'
import PasswordResetModel from '@/models/passwordReset'
import type {IPasswordReset} from '@/modelTypes/IPasswordReset'
export default class PasswordResetService extends AbstractService<IPasswordReset> {
constructor() {
super({})
this.paths = {
reset: '/user/password/reset',
requestReset: '/user/password/token',
}
}
modelFactory(data) {
return new PasswordResetModel(data)
}
async resetPassword(model) {
const cancel = this.setLoading()
try {
const response = await this.http.post(this.paths.reset, model)
return this.modelFactory(response.data)
} finally {
cancel()
}
}
async requestResetPassword(model) {
const cancel = this.setLoading()
try {
const response = await this.http.post(this.paths.requestReset, model)
return this.modelFactory(response.data)
} finally {
cancel()
}
}
}

View File

@ -1,10 +0,0 @@
import AbstractService from './abstractService'
import type {IPasswordUpdate} from '@/modelTypes/IPasswordUpdate'
export default class PasswordUpdateService extends AbstractService<IPasswordUpdate> {
constructor() {
super({
update: '/user/password',
})
}
}

View File

@ -4,17 +4,53 @@ import type {IProject} from '@/modelTypes/IProject'
import TaskService from './task'
import {colorFromHex} from '@/helpers/color/colorFromHex'
function getAllProjects() {
return [
{
id: 1,
title: 'Project',
description: '',
identifier: '',
hexColor: '',
namespaceId: 1,
owner: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-06T12:49:35+01:00',
},
isArchived: false,
backgroundInformation: null,
backgroundBlurHash: '',
isFavorite: false,
position: 65536,
created: '2021-05-30T10:45:30+02:00',
updated: '2023-03-06T13:38:43+01:00',
},
]
}
export default class ProjectService extends AbstractService<IProject> {
constructor() {
super({
create: '/namespaces/{namespaceId}/projects',
// create: '/namespaces/{namespaceId}/projects',
get: '/projects/{id}',
getAll: '/projects',
update: '/projects/{id}',
delete: '/projects/{id}',
// update: '/projects/{id}',
// delete: '/projects/{id}',
})
}
_getAll: () => IProject[] = () => {
return getAllProjects()
}
_get = (model: IProject, params: Record<string, string>) => {
// TODO_OFFLINE throw if `id` is not the only query parameter, for easier debugging during this
// prototyping.
return getAllProjects().find(p => p.id === model.id)
}
modelFactory(data) {
return new ProjectModel(data)
}

View File

@ -6,6 +6,7 @@ import LabelService from './label'
import {colorFromHex} from '@/helpers/color/colorFromHex'
import {SECONDS_A_DAY, SECONDS_A_HOUR, SECONDS_A_WEEK, SECONDS_A_MONTH, SECONDS_A_YEAR} from '@/constants/date'
import { createTask, deleteTask, getAllTasks, getTask, updateTask } from '@/localBackend/tasks'
const parseDate = date => {
if (date) {
@ -26,6 +27,12 @@ export default class TaskService extends AbstractService<ITask> {
})
}
_getAll = () => getAllTasks()
_get = (model: ITask) => getTask(model.id)
_create = (taskData: ITask) => createTask(taskData)
_update = (model: ITask) => updateTask(model)
_delete = (model: ITask) => deleteTask(model)
modelFactory(data) {
return new TaskModel(data)
}

View File

@ -2,6 +2,7 @@ import AbstractService from '@/services/abstractService'
import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import { getTasksOfProject } from '@/localBackend/tasks'
// FIXME: unite with other filter params types
export interface GetAllTasksParams {
@ -21,6 +22,12 @@ export default class TaskCollectionService extends AbstractService<ITask> {
})
}
// _getAll(): ITask[] {
_getAll: (model: { projectId: number }) => ITask[] = ({ projectId }) => {
// TODO_OFFLINE
return getTasksOfProject(projectId)
}
modelFactory(data) {
return new TaskModel(data)
}

View File

@ -1,38 +0,0 @@
import AbstractService from './abstractService'
import TotpModel from '@/models/totp'
import type {ITotp} from '@/modelTypes/ITotp'
export default class TotpService extends AbstractService<ITotp> {
urlPrefix = '/user/settings/totp'
constructor() {
super({})
this.paths.get = this.urlPrefix
}
modelFactory(data) {
return new TotpModel(data)
}
enroll() {
return this.post(`${this.urlPrefix}/enroll`, {})
}
enable(model) {
return this.post(`${this.urlPrefix}/enable`, model)
}
disable(model) {
return this.post(`${this.urlPrefix}/disable`, model)
}
async qrcode() {
const response = await this.http({
url: `${this.urlPrefix}/qrcode`,
method: 'GET',
responseType: 'blob',
})
return new Blob([response.data])
}
}

View File

@ -208,22 +208,8 @@ export const useAuthStore = defineStore('auth', () => {
return
}
const jwt = getToken()
let isAuthenticated = false
if (jwt) {
const base64 = jwt
.split('.')[1]
.replace('-', '+')
.replace('_', '/')
const info = new UserModel(JSON.parse(atob(base64)))
const ts = Math.round((new Date()).getTime() / MILLISECONDS_A_SECOND)
isAuthenticated = info.exp >= ts
setUser(info)
if (isAuthenticated) {
await refreshUserInfo()
}
}
const isAuthenticated = true
await refreshUserInfo()
setAuthenticated(isAuthenticated)
if (!isAuthenticated) {
@ -235,19 +221,35 @@ export const useAuthStore = defineStore('auth', () => {
}
async function refreshUserInfo() {
const jwt = getToken()
if (!jwt) {
return
}
const HTTP = AuthenticatedHTTPFactory()
try {
const response = await HTTP.get('user')
// TODO_OFFLINE
const newUser = new UserModel({
...response.data,
...(info.value?.type && {type: info.value?.type}),
...(info.value?.email && {email: info.value?.email}),
...(info.value?.exp && {exp: info.value?.exp}),
maxRight: null,
id: 1,
email: 'demo@vikunja.io',
username: 'demo',
name: '',
// TODO_OFFLINE how do we make it never expire?
exp: 1678108752,
type: 1,
created: '2021-05-30T08:45:25.000Z',
updated: '2023-03-03T10:50:28.000Z',
settings: {
maxRight: null,
name: '',
emailRemindersEnabled: false,
discoverableByName: false,
discoverableByEmail: false,
overdueTasksRemindersEnabled: false,
overdueTasksRemindersTime: '09:00',
// TODO_OFFLINE this is 0 by default.
defaultProjectId: 1,
weekStart: 1,
timezone: 'Europe/Moscow',
language: 'en',
},
isLocalUser: true,
deletionScheduledAt: '0001-01-01T00:00:00Z',
})
setUser(newUser)

View File

@ -80,10 +80,48 @@ export const useConfigStore = defineStore('config', () => {
Object.assign(state, config)
}
async function update(): Promise<boolean> {
const HTTP = HTTPFactory()
const {data: config} = await HTTP.get('info')
setConfig(objectToCamelCase(config))
const success = !!config
// TODO_OFFLINE it's just a stub currently
setConfig({
version: 'None whatsoever',
frontendUrl: 'https://try.vikunja.io/',
motd: '',
linkSharingEnabled: false,
maxFileSize: '20MB',
registrationEnabled: false,
availableMigrators: [
'vikunja-file',
// These require the internet.
// 'ticktick',
// 'todoist',
],
taskAttachmentsEnabled: false,
enabledBackgroundProviders: [
'upload',
// 'unsplash',
],
totpEnabled: false,
legal: {
imprintUrl: '',
privacyPolicyUrl: '',
},
// TODO_OFFLINE implement
caldavEnabled: false,
auth: {
local: {
enabled: true,
},
openidConnect: {
enabled: false,
redirectUrl: 'https://try.vikunja.io/auth/openid/',
providers: null,
},
},
emailRemindersEnabled: false,
userDeletionEnabled: false,
// TODO_OFFLINE implement comments
taskCommentsEnabled: false,
})
const success = true
return success
}

View File

@ -22,9 +22,7 @@
<x-button @click="setDefaultFilters">Reset</x-button>
</div>
</div>
<fancycheckbox class="is-block" v-model="filters.showTasksWithoutDates">
{{ $t('project.gantt.showTasksWithoutDates') }}
</fancycheckbox>
<div></div>
</div>
</card>
</template>

View File

@ -5,11 +5,6 @@
viewName="kanban"
>
<template #header>
<div class="filter-container" v-if="!isSavedFilter(project)">
<div class="items">
<filter-popup v-model="params" />
</div>
</div>
</template>
<template #default>
@ -415,6 +410,7 @@ async function updateTaskPosition(e) {
: 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
@ -425,6 +421,13 @@ async function updateTaskPosition(e) {
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 &&
newBucket.isDoneBucket !== oldBucket.isDoneBucket
) {
newTask.done = newBucket.isDoneBucket
}
try {
await taskStore.update(newTask)

View File

@ -1,50 +1,6 @@
<template>
<ProjectWrapper class="project-list" :project-id="projectId" viewName="project">
<template #header>
<div
class="filter-container"
v-if="!isSavedFilter(project)"
>
<div class="items">
<div class="search">
<div :class="{ hidden: !showTaskSearch }" class="field has-addons">
<div class="control has-icons-left has-icons-right">
<input
@blur="hideSearchBar()"
@keyup.enter="searchTasks"
class="input"
:placeholder="$t('misc.search')"
type="text"
v-focus
v-model="searchTerm"
/>
<span class="icon is-left">
<icon icon="search"/>
</span>
</div>
<div class="control">
<x-button
:loading="loading"
@click="searchTasks"
:shadow="false"
>
{{ $t('misc.search') }}
</x-button>
</div>
</div>
<x-button
@click="showTaskSearch = !showTaskSearch"
icon="search"
variant="secondary"
v-if="!showTaskSearch"
/>
</div>
<filter-popup
v-model="params"
@update:modelValue="prepareFiltersAndLoadTasks()"
/>
</div>
</div>
</template>
<template #default>
@ -152,7 +108,6 @@ const props = defineProps({
})
const ctaVisible = ref(false)
const showTaskSearch = ref(false)
const drag = ref(false)
const DRAG_OPTIONS = {
@ -160,21 +115,20 @@ const DRAG_OPTIONS = {
ghostClass: 'task-ghost',
} as const
// TODO_OFFLINE load tasks from somewhere else
const {
tasks,
loading,
totalPages,
currentPage,
loadTasks,
searchTerm,
params,
sortByParam,
// searchTerm,
// params,
// sortByParam,
} = useTaskList(toRef(props, 'projectId'), {position: 'asc' })
const isAlphabeticalSorting = computed(() => {
return params.value.sort_by.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
})
const isAlphabeticalSorting = computed(() => false)
const firstNewPosition = computed(() => {
if (tasks.value.length === 0) {
@ -200,29 +154,6 @@ onMounted(async () => {
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()
@ -267,15 +198,6 @@ async function saveTaskPosition(e) {
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>

View File

@ -1,63 +1,6 @@
<template>
<ProjectWrapper class="project-table" :project-id="projectId" viewName="table">
<template #header>
<div class="filter-container">
<div class="items">
<popup>
<template #trigger="{toggle}">
<x-button
@click.prevent.stop="toggle()"
icon="th"
variant="secondary"
>
{{ $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>
<filter-popup v-model="params"/>
</div>
</div>
</template>
<template #default>
@ -69,19 +12,15 @@
<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') }}
@ -91,27 +30,21 @@
</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') }}
@ -231,35 +164,22 @@ const SORT_BY_DEFAULT: SortBy = {
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
const taskList = useTaskList(toRef(props, 'projectId'), sortBy.value)
const taskList = useTaskList(toRef(props, 'projectId'))
const {
loading,
params,
// params,
totalPages,
currentPage,
sortByParam,
// 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
}
// Object.assign(params.value, {
// filter_by: [],
// filter_value: [],
// filter_comparator: [],
// })
// TODO: re-enable opening task detail in modal
// const router = useRouter()

View File

@ -279,8 +279,6 @@
</div>
</div>
<!-- Comments -->
<comments :can-write="canWrite" :task-id="taskId"/>
</div>
<div class="column is-one-third action-buttons d-print-none" v-if="canWrite || isModal">
<template v-if="canWrite">
@ -295,29 +293,6 @@
>
{{ task.done ? $t('task.detail.undone') : $t('task.detail.done') }}
</x-button>
<task-subscription
entity="task"
:entity-id="task.id"
:model-value="task.subscription"
@update:model-value="sub => task.subscription = sub"
/>
<x-button
@click="setFieldActive('assignees')"
variant="secondary"
v-shortcut="'a'"
v-cy="'taskDetail.assign'"
>
<span class="icon is-small"><icon icon="users"/></span>
{{ $t('task.detail.actions.assign') }}
</x-button>
<x-button
@click="setFieldActive('labels')"
variant="secondary"
icon="tags"
v-shortcut="'l'"
>
{{ $t('task.detail.actions.label') }}
</x-button>
<x-button
@click="setFieldActive('priority')"
variant="secondary"
@ -347,21 +322,6 @@
>
{{ $t('task.detail.actions.endDate') }}
</x-button>
<x-button
@click="setFieldActive('reminders')"
variant="secondary"
:icon="['far', 'clock']"
v-shortcut="'Alt+r'"
>
{{ $t('task.detail.actions.reminders') }}
</x-button>
<x-button
@click="setFieldActive('repeatAfter')"
variant="secondary"
icon="history"
>
{{ $t('task.detail.actions.repeatAfter') }}
</x-button>
<x-button
@click="setFieldActive('percentDone')"
variant="secondary"
@ -369,30 +329,6 @@
>
{{ $t('task.detail.actions.percentDone') }}
</x-button>
<x-button
@click="setFieldActive('attachments')"
variant="secondary"
icon="paperclip"
v-shortcut="'f'"
>
{{ $t('task.detail.actions.attachments') }}
</x-button>
<x-button
@click="setFieldActive('relatedTasks')"
variant="secondary"
icon="sitemap"
v-shortcut="'r'"
>
{{ $t('task.detail.actions.relatedTasks') }}
</x-button>
<x-button
@click="setFieldActive('moveProject')"
variant="secondary"
icon="list"
v-shortcut="'m'"
>
{{ $t('task.detail.actions.moveProject') }}
</x-button>
<x-button
@click="setFieldActive('color')"
variant="secondary"

View File

@ -1,219 +0,0 @@
<template>
<div>
<message variant="success" text-align="center" class="mb-4" v-if="confirmedEmailSuccess">
{{ $t('user.auth.confirmEmailSuccess') }}
</message>
<message variant="danger" v-if="errorMessage" class="mb-4">
{{ errorMessage }}
</message>
<form @submit.prevent="submit" id="loginform" v-if="localAuthEnabled">
<div class="field">
<label class="label" for="username">{{ $t('user.auth.usernameEmail') }}</label>
<div class="control">
<input
class="input" id="username"
name="username"
:placeholder="$t('user.auth.usernamePlaceholder')"
ref="usernameRef"
required
type="text"
autocomplete="username"
v-focus
@keyup.enter="submit"
tabindex="1"
@focusout="validateUsernameField()"
/>
</div>
<p class="help is-danger" v-if="!usernameValid">
{{ $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 tabindex="2" @submit="submit" v-model="password" :validate-initially="validatePasswordInitially"/>
</div>
<div class="field" v-if="needsTotpPasscode">
<label class="label" for="totpPasscode">{{ $t('user.auth.totpTitle') }}</label>
<div class="control">
<input
autocomplete="one-time-code"
class="input"
id="totpPasscode"
:placeholder="$t('user.auth.totpPlaceholder')"
ref="totpPasscode"
required
type="text"
v-focus
@keyup.enter="submit"
tabindex="3"
inputmode="numeric"
/>
</div>
</div>
<div class="field">
<label class="label">
<input type="checkbox" v-model="rememberMe" class="mr-1"/>
{{ $t('user.auth.remember') }}
</label>
</div>
<x-button
@click="submit"
:loading="isLoading"
tabindex="4"
>
{{ $t('user.auth.login') }}
</x-button>
<p class="mt-2" v-if="registrationEnabled">
{{ $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"
@click="redirectToProvider(p)"
variant="secondary"
class="is-fullwidth mt-2"
>
{{ $t('user.auth.loginWith', {provider: p.name}) }}
</x-button>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, onBeforeMount, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {useDebounceFn} from '@vueuse/core'
import Message from '@/components/misc/message.vue'
import Password from '@/components/input/password.vue'
import {getErrorText} from '@/message'
import {redirectToProvider} from '@/helpers/redirectToProvider'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
import {useTitle} from '@/composables/useTitle'
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('user.auth.login'))
const authStore = useAuthStore()
const configStore = useConfigStore()
const {redirectIfSaved} = useRedirectToLastVisited()
const registrationEnabled = computed(() => configStore.registrationEnabled)
const localAuthEnabled = computed(() => configStore.auth.local.enabled)
const openidConnect = computed(() => configStore.auth.openidConnect)
const hasOpenIdProviders = computed(() => openidConnect.value.enabled && openidConnect.value.providers?.length > 0)
const isLoading = computed(() => authStore.isLoading)
const confirmedEmailSuccess = ref(false)
const errorMessage = ref('')
const password = ref('')
const validatePasswordInitially = ref(false)
const rememberMe = ref(false)
const authenticated = computed(() => authStore.authenticated)
onBeforeMount(() => {
authStore.verifyEmail().then((confirmed) => {
confirmedEmailSuccess.value = confirmed
}).catch((e: Error) => {
errorMessage.value = e.message
})
// Check if the user is already logged in, if so, redirect them to the homepage
if (authenticated.value) {
redirectIfSaved()
}
})
const usernameValid = ref(true)
const usernameRef = ref<HTMLInputElement | null>(null)
const validateUsernameField = useDebounceFn(() => {
usernameValid.value = usernameRef.value?.value !== ''
}, 100)
const needsTotpPasscode = computed(() => authStore.needsTotpPasscode)
const totpPasscode = ref<HTMLInputElement | null>(null)
async function submit() {
errorMessage.value = ''
// Some browsers prevent Vue bindings from working with autofilled values.
// To work around this, we're manually getting the values here instead of relying on vue bindings.
// For more info, see https://kolaente.dev/vikunja/frontend/issues/78
const credentials = {
username: usernameRef.value?.value,
password: password.value,
longToken: rememberMe.value,
}
if (credentials.username === '' || credentials.password === '') {
// Trigger the validation error messages
validateUsernameField()
validatePasswordInitially.value = true
return
}
if (needsTotpPasscode.value) {
credentials.totpPasscode = totpPasscode.value?.value
}
try {
await authStore.login(credentials)
authStore.setNeedsTotpPasscode(false)
} catch (e) {
if (e.response?.data.code === 1017 && !credentials.totpPasscode) {
return
}
errorMessage.value = getErrorText(e)
}
}
</script>
<style lang="scss" scoped>
.button {
margin: 0 0.4rem 0 0;
}
.reset-password-link {
display: inline-block;
}
.label-with-link {
display: flex;
justify-content: space-between;
margin-bottom: .5rem;
.label {
margin-bottom: 0;
}
}
</style>

View File

@ -1,86 +0,0 @@
<template>
<div>
<message variant="danger" v-if="errorMessage">
{{ errorMessage }}
</message>
<message variant="danger" v-if="errorMessageFromQuery" class="mt-2">
{{ errorMessageFromQuery }}
</message>
<message v-if="loading">
{{ $t('user.auth.authenticating') }}
</message>
</div>
</template>
<script lang="ts">
export default { name: 'Auth' }
</script>
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {getErrorText} from '@/message'
import Message from '@/components/misc/message.vue'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {useAuthStore} from '@/stores/auth'
const {t} = useI18n({useScope: 'global'})
const route = useRoute()
const {redirectIfSaved} = useRedirectToLastVisited()
const authStore = useAuthStore()
const loading = computed(() => authStore.isLoading)
const errorMessage = ref('')
const errorMessageFromQuery = computed(() => route.query.error)
async function authenticateWithCode() {
// This component gets mounted twice: The first time when the actual auth request hits the frontend,
// the second time after that auth request succeeded and the outer component "content-no-auth" isn't used
// but instead the "content-auth" component is used. Because this component is just a route and thus
// gets mounted as part of a <router-view/> which both the content-auth and content-no-auth components have,
// this re-mounts the component, even if the user is already authenticated.
// To make sure we only try to authenticate the user once, we set this "authenticating" lock in localStorage
// which ensures only one auth request is done at a time. We don't simply check if the user is already
// authenticated to not prevent the whole authentication if some user is already logged in.
if (localStorage.getItem('authenticating')) {
return
}
localStorage.setItem('authenticating', 'true')
errorMessage.value = ''
if (typeof route.query.error !== 'undefined') {
localStorage.removeItem('authenticating')
errorMessage.value = typeof route.query.message !== 'undefined'
? route.query.message as string
: t('user.auth.openIdGeneralError')
return
}
const state = localStorage.getItem('state')
if (typeof route.query.state === 'undefined' || route.query.state !== state) {
localStorage.removeItem('authenticating')
errorMessage.value = t('user.auth.openIdStateError')
return
}
try {
await authStore.openIdAuth({
provider: route.params.provider,
code: route.query.code,
})
redirectIfSaved()
} catch(e) {
errorMessage.value = getErrorText(e)
} finally {
localStorage.removeItem('authenticating')
}
}
onMounted(() => authenticateWithCode())
</script>

View File

@ -1,72 +0,0 @@
<template>
<div>
<message v-if="errorMsg" class="mb-4">
{{ errorMsg }}
</message>
<div class="has-text-centered mb-4" v-if="successMessage">
<message variant="success">
{{ successMessage }}
</message>
<x-button :to="{ name: 'user.login' }" class="mt-4">
{{ $t('user.auth.login') }}
</x-button>
</div>
<form @submit.prevent="submit" id="form" v-if="!successMessage">
<div class="field">
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
<Password @submit="submit" @update:modelValue="v => credentials.password = v"/>
</div>
<div class="field is-grouped">
<div class="control">
<x-button
:loading="passwordResetService.loading"
@click="submit"
>
{{ $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 submit() {
errorMsg.value = ''
if(credentials.password === '') {
return
}
const passwordReset = new PasswordResetModel({newPassword: credentials.password})
try {
const {message} = await passwordResetService.resetPassword(passwordReset)
successMessage.value = message
localStorage.removeItem('passwordResetToken')
} catch (e) {
errorMsg.value = e.response.data.message
}
}
</script>
<style scoped>
.button {
margin: 0 0.4rem 0 0;
}
</style>

View File

@ -1,137 +0,0 @@
<template>
<div>
<message variant="danger" v-if="errorMessage !== ''" class="mb-4">
{{ errorMessage }}
</message>
<form @submit.prevent="submit" id="registerform">
<div class="field">
<label class="label" for="username">{{ $t('user.auth.username') }}</label>
<div class="control">
<input
class="input"
id="username"
name="username"
:placeholder="$t('user.auth.usernamePlaceholder')"
required
type="text"
autocomplete="username"
v-focus
v-model="credentials.username"
@keyup.enter="submit"
@focusout="validateUsername"
/>
</div>
<p class="help is-danger" v-if="!usernameValid">
{{ $t('user.auth.usernameRequired') }}
</p>
</div>
<div class="field">
<label class="label" for="email">{{ $t('user.auth.email') }}</label>
<div class="control">
<input
class="input"
id="email"
name="email"
:placeholder="$t('user.auth.emailPlaceholder')"
required
type="email"
v-model="credentials.email"
@keyup.enter="submit"
@focusout="validateEmail"
/>
</div>
<p class="help is-danger" v-if="!emailValid">
{{ $t('user.auth.emailInvalid') }}
</p>
</div>
<div class="field">
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
<password @submit="submit" @update:modelValue="v => credentials.password = v" :validate-initially="validatePasswordInitially"/>
</div>
<x-button
:loading="isLoading"
id="register-submit"
@click="submit"
class="mr-2"
:disabled="!everythingValid"
>
{{ $t('user.auth.createAccount') }}
</x-button>
<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'
const authStore = useAuthStore()
// 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: any) {
errorMessage.value = e?.message
}
}
</script>

View File

@ -1,75 +0,0 @@
<template>
<div>
<message variant="danger" v-if="errorMsg" class="mb-4">
{{ errorMsg }}
</message>
<div class="has-text-centered mb-4" v-if="isSuccess">
<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 @submit.prevent="submit" v-if="!isSuccess">
<div class="field">
<label class="label" for="email">{{ $t('user.auth.email') }}</label>
<div class="control">
<input
class="input"
id="email"
name="email"
:placeholder="$t('user.auth.emailPlaceholder')"
required
type="email"
v-focus
v-model="passwordReset.email"/>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<x-button
@click="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, reactive} from 'vue'
import PasswordResetModel from '@/models/passwordReset'
import PasswordResetService from '@/services/passwordReset'
import Message from '@/components/misc/message.vue'
// Not sure if this instance needs a shalloRef at all
const passwordResetService = reactive(new PasswordResetService())
const passwordReset = ref(new PasswordResetModel())
const errorMsg = ref('')
const isSuccess = ref(false)
async function submit() {
errorMsg.value = ''
try {
await passwordResetService.requestResetPassword(passwordReset.value)
isSuccess.value = true
} catch (e) {
errorMsg.value = e.response.data.message
}
}
</script>
<style scoped>
.button {
margin: 0 0.4rem 0 0;
}
</style>

View File

@ -42,44 +42,8 @@ const navigationItems = computed(() => {
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.deletion.title'),
routeName: 'user.settings.deletion',
condition: userDeletionEnabled.value,
},
// TODO_OFFLINE export/import
// TODO_OFFLINE caldav
]
return items.filter(({condition}) => condition !== false)

View File

@ -1,139 +0,0 @@
<template>
<card :title="$t('user.deletion.title')" v-if="userDeletionEnabled">
<template v-if="deletionScheduledAt !== null">
<form @submit.prevent="cancelDeletion()">
<p>
{{
$t('user.deletion.scheduled', {
date: formatDateShort(deletionScheduledAt),
dateSince: formatDateSince(deletionScheduledAt),
})
}}
</p>
<p>
{{ $t('user.deletion.scheduledCancelText') }}
</p>
<div class="field">
<label class="label" for="currentPasswordAccountDelete">
{{ $t('user.settings.currentPassword') }}
</label>
<div class="control">
<input
class="input"
:class="{'is-danger': errPasswordRequired}"
id="currentPasswordAccountDelete"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="password"
@keyup="() => errPasswordRequired = password === ''"
ref="passwordInput"
/>
</div>
<p class="help is-danger" v-if="errPasswordRequired">
{{ $t('user.deletion.passwordRequired') }}
</p>
</div>
</form>
<x-button
:loading="accountDeleteService.loading"
@click="cancelDeletion()"
class="is-fullwidth mt-4">
{{ $t('user.deletion.scheduledCancelConfirm') }}
</x-button>
</template>
<template v-else>
<form @submit.prevent="deleteAccount()">
<p>
{{ $t('user.deletion.text1') }}
</p>
<p>
{{ $t('user.deletion.text2') }}
</p>
<div class="field">
<label class="label" for="currentPasswordAccountDelete">
{{ $t('user.settings.currentPassword') }}
</label>
<div class="control">
<input
class="input"
:class="{'is-danger': errPasswordRequired}"
id="currentPasswordAccountDelete"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="password"
@keyup="() => errPasswordRequired = password === ''"
ref="passwordInput"
/>
</div>
<p class="help is-danger" v-if="errPasswordRequired">
{{ $t('user.deletion.passwordRequired') }}
</p>
</div>
</form>
<x-button
:loading="accountDeleteService.loading"
@click="deleteAccount()"
class="is-fullwidth mt-4 is-danger">
{{ $t('user.deletion.confirm') }}
</x-button>
</template>
</card>
</template>
<script lang="ts">
export default { name: 'user-settings-deletion' }
</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 passwordInput = ref()
async function deleteAccount() {
if (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 (password.value === '') {
errPasswordRequired.value = true
passwordInput.value.focus()
return
}
await accountDeleteService.cancel(password.value)
success({message: t('user.deletion.scheduledCancelSuccess')})
authStore.refreshUserInfo()
password.value = ''
}
</script>

View File

@ -1,65 +0,0 @@
<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
@keyup.enter="updateEmail"
class="input"
id="newEmail"
:placeholder="$t('user.auth.emailPlaceholder')"
type="email"
v-model="emailUpdate.newEmail"/>
</div>
</div>
<div class="field">
<label class="label" for="currentPasswordEmail">{{ $t('user.settings.currentPassword') }}</label>
<div class="control">
<input
@keyup.enter="updateEmail"
class="input"
id="currentPasswordEmail"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="emailUpdate.password"/>
</div>
</div>
</form>
<x-button
:loading="emailUpdateService.loading"
@click="updateEmail"
class="is-fullwidth mt-4">
{{ $t('misc.save') }}
</x-button>
</card>
</template>
<script lang="ts">
export default { name: 'user-settings-update-email' }
</script>
<script setup lang="ts">
import {reactive, computed, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import EmailUpdateService from '@/services/emailUpdate'
import EmailUpdateModel from '@/models/emailUpdate'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useAuthStore} from '@/stores/auth'
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.settings.updateEmailTitle')} - ${t('user.settings.title')}`)
const authStore = useAuthStore()
const isLocalUser = computed(() => authStore.info?.isLocalUser)
const emailUpdate = reactive(new EmailUpdateModel())
const emailUpdateService = shallowReactive(new EmailUpdateService())
async function updateEmail() {
await emailUpdateService.update(emailUpdate)
success({message: t('user.settings.updateEmailSuccess')})
}
</script>

View File

@ -1,79 +1,11 @@
<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
@keyup.enter="updateSettings"
class="input"
:id="`newName${id}`"
:placeholder="$t('user.settings.general.newName')"
type="text"
v-model="settings.name"/>
</div>
</div>
<div class="field">
<label class="label">
{{ $t('user.settings.general.defaultProject') }}
</label>
<project-search v-model="defaultProject"/>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="settings.overdueTasksRemindersEnabled"/>
{{ $t('user.settings.general.overdueReminders') }}
</label>
</div>
<div class="field" v-if="settings.overdueTasksRemindersEnabled">
<label class="label" for="overdueTasksReminderTime">
{{ $t('user.settings.general.overdueTasksRemindersTime') }}
</label>
<div class="control">
<input
@keyup.enter="updateSettings"
class="input"
id="overdueTasksReminderTime"
type="time"
v-model="settings.overdueTasksRemindersTime"/>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="settings.emailRemindersEnabled"/>
{{ $t('user.settings.general.emailReminders') }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="settings.discoverableByName"/>
{{ $t('user.settings.general.discoverableByName') }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="settings.discoverableByEmail"/>
{{ $t('user.settings.general.discoverableByEmail') }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="playSoundWhenDone"/>
{{ $t('user.settings.general.playSoundWhenDone') }}
</label>
</div>
<div class="field">
<label class="is-flex is-align-items-center">
<span>
{{ $t('user.settings.general.weekStart') }}
</span>
<div class="select ml-2">
<select v-model.number="settings.weekStart">
<option value="0">{{ $t('user.settings.general.weekStartSunday') }}</option>
<option value="1">{{ $t('user.settings.general.weekStartMonday') }}</option>
</select>
</div>
</label>
</div>
<div class="field">
<label class="is-flex is-align-items-center">
<span>
@ -91,6 +23,7 @@
</div>
</label>
</div>
<!-- TODO support week start, others that are possible -->
<div class="field">
<label class="is-flex is-align-items-center">
<span>
@ -120,20 +53,6 @@
</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
:loading="loading"

View File

@ -1,88 +0,0 @@
<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
autocomplete="new-password"
@keyup.enter="updatePassword"
class="input"
id="newPassword"
:placeholder="$t('user.auth.passwordPlaceholder')"
type="password"
v-model="passwordUpdate.newPassword"/>
</div>
</div>
<div class="field">
<label class="label" for="newPasswordConfirm">{{ $t('user.settings.newPasswordConfirm') }}</label>
<div class="control">
<input
autocomplete="new-password"
@keyup.enter="updatePassword"
class="input"
id="newPasswordConfirm"
:placeholder="$t('user.auth.passwordPlaceholder')"
type="password"
v-model="passwordConfirm"/>
</div>
</div>
<div class="field">
<label class="label" for="currentPassword">{{ $t('user.settings.currentPassword') }}</label>
<div class="control">
<input
autocomplete="current-password"
@keyup.enter="updatePassword"
class="input"
id="currentPassword"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="passwordUpdate.oldPassword"/>
</div>
</div>
</form>
<x-button
:loading="passwordUpdateService.loading"
@click="updatePassword"
class="is-fullwidth mt-4">
{{ $t('misc.save') }}
</x-button>
</card>
</template>
<script lang="ts">
export default {name: 'user-settings-password-update'}
</script>
<script setup lang="ts">
import {ref, reactive, shallowReactive, computed} from 'vue'
import {useI18n} from 'vue-i18n'
import PasswordUpdateService from '@/services/passwordUpdateService'
import PasswordUpdateModel from '@/models/passwordUpdate'
import {useTitle} from '@/composables/useTitle'
import {success, error} from '@/message'
import {useAuthStore} from '@/stores/auth'
const passwordUpdateService = shallowReactive(new PasswordUpdateService())
const passwordUpdate = reactive(new PasswordUpdateModel())
const passwordConfirm = ref('')
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.settings.newPasswordTitle')} - ${t('user.settings.title')}`)
const authStore = useAuthStore()
const isLocalUser = computed(() => authStore.info?.isLocalUser)
async function updatePassword() {
if (passwordConfirm.value !== passwordUpdate.newPassword) {
error({message: t('user.settings.passwordsDontMatch')})
return
}
await passwordUpdateService.update(passwordUpdate)
success({message: t('user.settings.passwordUpdateSuccess')})
}
</script>

View File

@ -1,142 +0,0 @@
<template>
<card :title="$t('user.settings.totp.title')" v-if="totpEnabled">
<x-button
:loading="totpService.loading"
@click="totpEnroll()"
v-if="!totpEnrolled && totp.secret === ''">
{{ $t('user.settings.totp.enroll') }}
</x-button>
<template v-else-if="totp.secret !== '' && !totp.enabled">
<p>
{{ $t('user.settings.totp.finishSetupPart1') }}
<strong>{{ totp.secret }}</strong><br/>
{{ $t('user.settings.totp.finishSetupPart2') }}
</p>
<p>
{{ $t('user.settings.totp.scanQR') }}<br/>
<img :src="totpQR" alt=""/>
</p>
<div class="field">
<label class="label" for="totpConfirmPasscode">{{ $t('user.settings.totp.passcode') }}</label>
<div class="control">
<input
autocomplete="one-time-code"
@keyup.enter="totpConfirm"
class="input"
id="totpConfirmPasscode"
:placeholder="$t('user.settings.totp.passcodePlaceholder')"
type="text"
inputmode="numeric"
v-model="totpConfirmPasscode"/>
</div>
</div>
<x-button @click="totpConfirm">{{ $t('misc.confirm') }}</x-button>
</template>
<template v-else-if="totp.secret !== '' && totp.enabled">
<p>
{{ $t('user.settings.totp.setupSuccess') }}
</p>
<p v-if="!totpDisableForm">
<x-button @click="totpDisableForm = true" class="is-danger">{{ $t('misc.disable') }}</x-button>
</p>
<div v-if="totpDisableForm">
<div class="field">
<label class="label" for="currentPassword">{{ $t('user.settings.totp.enterPassword') }}</label>
<div class="control">
<input
@keyup.enter="totpDisable"
class="input"
id="currentPassword"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-focus
v-model="totpDisablePassword"/>
</div>
</div>
<x-button @click="totpDisable" class="is-danger">
{{ $t('user.settings.totp.disable') }}
</x-button>
<x-button @click="totpDisableForm = false" variant="tertiary" class="ml-2">
{{ $t('misc.cancel') }}
</x-button>
</div>
</template>
</card>
</template>
<script lang="ts">
export default { name: 'user-settings-totp' }
</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>