Vuex #126

Merged
konrad merged 30 commits from feature/vuex into master 2020-05-08 18:43:52 +00:00
31 changed files with 501 additions and 393 deletions

View File

@ -20,7 +20,8 @@
"vue": "2.6.11",
"vue-drag-resize": "1.3.2",
"vue-easymde": "1.2.0",
"vue-smooth-dnd": "0.8.1"
"vue-smooth-dnd": "0.8.1",
"vuex": "^3.3.0"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "1.2.28",

View File

@ -1,10 +1,10 @@
<template>
<div>
<div v-if="isOnline">
<div v-if="online">
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image -->
<div class="offline" style="height: 0;width: 0;"></div>
<nav class="navbar main-theme is-fixed-top" role="navigation" aria-label="main navigation"
v-if="user.authenticated && (userInfo && userInfo.type === authTypes.USER)">
v-if="userAuthenticated && (userInfo && userInfo.type === authTypes.USER)">
<div class="navbar-brand">
<router-link :to="{name: 'home'}" class="navbar-item logo">
<img src="/images/logo-full.svg" alt="Vikunja"/>
@ -16,11 +16,11 @@
<a @click="refreshApp()" class="button is-primary noshadow">Update Now</a>
</div>
<div class="user">
<img :src="user.infos.getAvatarUrl()" class="avatar" alt=""/>
<img :src="userInfo.getAvatarUrl()" class="avatar" alt=""/>
<div class="dropdown is-right is-active">
<div class="dropdown-trigger">
<button class="button noshadow" @click="userMenuActive = !userMenuActive">
<span class="username">{{user.infos.username}}</span>
<span class="username">{{userInfo.username}}</span>
<span class="icon is-small">
<icon icon="chevron-down"/>
</span>
@ -42,7 +42,7 @@
</div>
</div>
</nav>
<div v-if="user.authenticated && (userInfo && userInfo.type === authTypes.USER)">
<div v-if="userAuthenticated && (userInfo && userInfo.type === authTypes.USER)">
<a @click="mobileMenuActive = true" class="mobilemenu-show-button" v-if="!mobileMenuActive">
<icon icon="bars"></icon>
</a>
@ -107,7 +107,7 @@
</ul>
</div>
<aside class="menu namespaces-lists">
<fancycheckbox v-model="showArchived" @change="loadNamespaces()" class="show-archived-check">
<fancycheckbox v-model="showArchived" class="show-archived-check">
Show Archived
</fancycheckbox>
<div class="spinner" :class="{ 'is-loading': namespaceService.loading}"></div>
@ -168,8 +168,7 @@
</div>
</div>
</div>
<!-- FIXME: This will only be triggered when the root component is already loaded before doing link share auth. Will "fix" itself once we use vuex. -->
<div v-else-if="user.authenticated && (userInfo && userInfo.type === authTypes.LINK_SHARE)">
<div v-else-if="userAuthenticated && (userInfo && userInfo.type === authTypes.LINK_SHARE)">
<div class="container has-text-centered link-share-view">
<div class="column is-10 is-offset-1">
<img src="/images/logo-full.svg" alt="Vikunja" class="logo"/>
@ -215,8 +214,8 @@
</template>
<script>
import auth from './auth'
import router from './router'
import {mapState} from 'vuex'
import NamespaceService from './services/namespace'
import authTypes from './models/authTypes'
@ -224,6 +223,7 @@
import swEvents from './ServiceWorker/events'
import Notification from './components/global/notification'
import Fancycheckbox from './components/global/fancycheckbox'
import {IS_FULLPAGE, ONLINE} from './store/mutation-types'
export default {
name: 'app',
@ -233,16 +233,11 @@
},
data() {
return {
user: auth.user,
namespaces: [],
namespaceService: NamespaceService,
mobileMenuActive: false,
fullpage: false,
currentDate: new Date(),
userMenuActive: false,
authTypes: authTypes,
isOnline: true,
motd: '',
showArchived: false,
// Service Worker stuff
@ -253,9 +248,9 @@
},
beforeMount() {
// Check if the user is offline, show a message then
this.isOnline = navigator.onLine
window.addEventListener('online', () => this.isOnline = navigator.onLine);
window.addEventListener('offline', () => this.isOnline = navigator.onLine);
this.$store.commit(ONLINE, navigator.onLine)
window.addEventListener('online', () => this.$store.commit(ONLINE, navigator.onLine));
window.addEventListener('offline', () => this.$store.commit(ONLINE, navigator.onLine));
// Password reset
if (this.$route.query.userPasswordReset !== undefined) {
@ -271,12 +266,15 @@
}
},
created() {
if (auth.user.authenticated && auth.user.infos.type === authTypes.USER && (this.$route.params.name === 'home' || this.namespaces.length === 0)) {
this.$store.dispatch('config/update')
this.$store.dispatch('auth/checkAuth')
if (this.userAuthenticated && this.userInfo.type === authTypes.USER && (this.$route.params.name === 'home' || this.namespaces.length === 0)) {
this.loadNamespaces()
}
// Service worker communication
document.addEventListener(swEvents.SW_UPDATED, this.showRefreshUI, { once: true })
document.addEventListener(swEvents.SW_UPDATED, this.showRefreshUI, {once: true})
navigator.serviceWorker.addEventListener(
'controllerchange', () => {
@ -288,72 +286,54 @@
// Schedule a token renew every minute
setTimeout(() => {
auth.renewToken()
this.$store.dispatch('auth/renewToken')
}, 1000 * 60)
// Set the motd
this.setMotd()
},
watch: {
// call the method again if the route changes
'$route': 'doStuffAfterRoute',
},
computed: {
userInfo() {
return auth.getUserInfos()
}
},
computed: mapState({
userInfo: state => state.auth.info,
userAuthenticated: state => state.auth.authenticated,
motd: state => state.config.motd,
online: ONLINE,
fullpage: IS_FULLPAGE,
namespaces(state) {
return state.namespaces.namespaces.filter(n => this.showArchived ? true : !n.isArchived)
},
}),
methods: {
logout() {
auth.logout()
this.$store.dispatch('auth/logout')
},
loadNamespaces() {
this.namespaceService = new NamespaceService()
this.namespaceService.getAll({}, {isArchived: this.showArchived})
.then(r => {
this.$set(this, 'namespaces', r)
})
.catch(e => {
this.error(e, this)
})
this.$store.dispatch('namespaces/loadNamespaces')
},
loadNamespacesIfNeeded(e) {
if (auth.user.authenticated && (this.userInfo && this.userInfo.type === authTypes.USER) && (e.name === 'home' || this.namespaces.length === 0)) {
if (this.userAuthenticated && (this.userInfo && this.userInfo.type === authTypes.USER) && (e.name === 'home' || this.namespaces.length === 0)) {
this.loadNamespaces()
}
},
doStuffAfterRoute(e) {
this.fullpage = false;
this.$store.commit(IS_FULLPAGE, false)
this.loadNamespacesIfNeeded(e)
this.mobileMenuActive = false
this.userMenuActive = false
},
setFullPage() {
this.fullpage = true;
},
showRefreshUI (e) {
showRefreshUI(e) {
console.log('recieved refresh event', e)
this.registration = e.detail;
this.updateAvailable = true;
},
refreshApp () {
refreshApp() {
this.updateExists = false;
if (!this.registration || !this.registration.waiting) { return; }
if (!this.registration || !this.registration.waiting) {
return;
}
// Notify the service worker to actually do the update
this.registration.waiting.postMessage('skipWaiting');
},
setMotd() {
let cancel = () => {};
// Since the config may not be initialized when we're calling this, we need to retry until it is ready.
if (typeof this.$config === 'undefined') {
cancel = setTimeout(() => {
this.setMotd()
}, 150)
} else {
cancel()
this.motd = this.$config.motd
}
},
},
}
</script>

View File

@ -1,148 +0,0 @@
import {HTTP} from '../http-common'
import router from '../router'
import UserModel from '../models/user'
// const API_URL = 'http://localhost:8082/api/v1/'
// const LOGIN_URL = 'http://localhost:8082/login'
export default {
user: {
authenticated: false,
infos: {},
},
login(context, credentials, redirect = '') {
localStorage.removeItem('token') // Delete an eventually preexisting old token
const data = {
username: credentials.username,
password: credentials.password
}
if(credentials.totpPasscode) {
data.totp_passcode = credentials.totpPasscode
}
HTTP.post('login', data)
.then(response => {
// Save the token to local storage for later use
localStorage.setItem('token', response.data.token)
// Tell others the user is autheticated
this.user.authenticated = true
this.user.isLinkShareAuth = false
// Redirect if nessecary
if (redirect !== '') {
router.push({name: redirect})
}
})
.catch(e => {
if (e.response) {
if (e.response.data.code === 1017 && !credentials.totpPasscode) {
context.needsTotpPasscode = true
return
}
context.errorMsg = e.response.data.message
if (e.response.status === 401) {
context.errorMsg = 'Wrong username or password.'
}
}
})
.finally(() => {
context.loading = false
})
},
register(context, creds, redirect) {
HTTP.post('register', {
username: creds.username,
email: creds.email,
password: creds.password
})
.then(() => {
this.login(context, creds, redirect)
})
.catch(e => {
// Hide the loader
context.loading = false
if (e.response) {
context.errorMsg = e.response.data.message
if (e.response.status === 401) {
context.errorMsg = 'Wrong username or password.'
}
}
})
},
logout() {
localStorage.removeItem('token')
router.push({name: 'login'})
this.user.authenticated = false
},
linkShareAuth(hash) {
return HTTP.post('/shares/' + hash + '/auth')
.then(r => {
localStorage.setItem('token', r.data.token)
this.getUserInfos()
return Promise.resolve(r.data)
}).catch(e => {
return Promise.reject(e)
})
},
renewToken() {
HTTP.post('user/token', null, {
headers: {
Authorization: 'Bearer ' + localStorage.getItem('token'),
}
})
.then(r => {
localStorage.setItem('token', r.data.token)
})
.catch(e => {
// eslint-disable-next-line
console.log('Error renewing token: ', e)
})
},
checkAuth() {
let jwt = localStorage.getItem('token')
this.getUserInfos()
this.user.authenticated = false
if (jwt) {
let ts = Math.round((new Date()).getTime() / 1000)
if (this.user.infos.exp >= ts) {
this.user.authenticated = true
}
}
},
getUserInfos() {
let jwt = localStorage.getItem('token')
if (jwt) {
this.user.infos = new UserModel(this.parseJwt(localStorage.getItem('token')))
return this.user.infos
} else {
return {}
}
},
parseJwt(token) {
let base64Url = token.split('.')[1]
let base64 = base64Url.replace('-', '+').replace('_', '/')
return JSON.parse(window.atob(base64))
},
getAuthHeader() {
return {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
},
getToken() {
return localStorage.getItem('token')
}
}

View File

@ -1,21 +1,26 @@
<template>
<div class="content has-text-centered">
<h2>Hi {{user.infos.username}}!</h2>
<h2>Hi {{userInfo.username}}!</h2>
<p>Click on a list or namespace on the left to get started.</p>
<router-link class="button is-primary is-right noshadow is-outlined" :to="{name: 'migrateStart'}">Import your data into Vikunja</router-link>
<router-link
class="button is-primary is-right noshadow is-outlined"
:to="{name: 'migrateStart'}"
v-if="migratorsEnabled"
>
Import your data into Vikunja
</router-link>
<TaskOverview :show-all="true"/>
</div>
</template>
<script>
import auth from '../auth'
import router from '../router'
import {mapState} from 'vuex'
export default {
name: "Home",
data() {
return {
user: auth.user,
loading: false,
currentDate: new Date(),
tasks: []
@ -23,14 +28,14 @@
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
if (!this.authenticated) {
router.push({name: 'login'})
}
},
methods: {
logout() {
auth.logout()
},
},
computed: mapState({
migratorsEnabled: state => state.config.availableMigrators !== null && state.config.availableMigrators.length > 0,
authenticated: state => state.auth.authenticated,
userInfo: state => state.auth.info,
}),
}
</script>

View File

@ -11,11 +11,11 @@
<span
v-for="l in labels" :key="l.id"
class="tag"
:class="{'disabled': user.infos.id !== l.createdBy.id}"
:class="{'disabled': userInfo.id !== l.createdBy.id}"
:style="{'background': l.hexColor, 'color': l.textColor}"
>
<span
v-if="user.infos.id !== l.createdBy.id"
v-if="userInfo.id !== l.createdBy.id"
v-tooltip.bottom="'You are not allowed to edit this label because you dont own it.'">
{{ l.title }}
</span>
@ -25,7 +25,7 @@
v-else>
{{ l.title }}
</a>
<a class="delete is-small" @click="deleteLabel(l)" v-if="user.infos.id === l.createdBy.id"></a>
<a class="delete is-small" @click="deleteLabel(l)" v-if="userInfo.id === l.createdBy.id"></a>
</span>
</div>
<div class="column is-4" v-if="isLabelEdit">
@ -102,10 +102,10 @@
<script>
import verte from 'verte'
import 'verte/dist/verte.css'
import {mapState} from 'vuex'
import LabelService from '../../services/label'
import LabelModel from '../../models/label'
import auth from '../../auth'
export default {
name: 'ListLabels',
@ -118,7 +118,6 @@
labels: [],
labelEditLabel: LabelModel,
isLabelEdit: false,
user: auth.user,
}
},
created() {
@ -126,6 +125,9 @@
this.labelEditLabel = new LabelModel()
this.loadLabels()
},
computed: mapState({
userInfo: state => state.auth.info
}),
methods: {
loadLabels() {
const getAllLabels = (page = 1) => {
@ -183,7 +185,7 @@
})
},
editLabel(label) {
if (label.createdBy.id !== this.user.infos.id) {
if (label.createdBy.id !== this.userInfo.id) {
return
}
this.labelEditLabel = label

View File

@ -68,7 +68,7 @@
<component :is="manageUsersComponent" :id="list.id" type="list" shareType="user" :userIsAdmin="userIsAdmin"></component>
<component :is="manageTeamsComponent" :id="list.id" type="list" shareType="team" :userIsAdmin="userIsAdmin"></component>
<link-sharing :list-id="$route.params.id"/>
<link-sharing :list-id="$route.params.id" v-if="linkSharingEnabled"/>
<modal
v-if="showDeleteModal"
@ -85,7 +85,6 @@
import verte from 'verte'
import 'verte/dist/verte.css'
import auth from '../../auth'
import router from '../../router'
import manageSharing from '../sharing/userTeam'
import LinkSharing from '../sharing/linkSharing'
@ -102,8 +101,6 @@
listService: ListService,
showDeleteModal: false,
user: auth.user,
userIsAdmin: false, // FIXME: we should be able to know somehow if the user is admin, not only based on if he's the owner
manageUsersComponent: '',
manageTeamsComponent: '',
@ -115,12 +112,6 @@
manageSharing,
verte,
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
},
created() {
this.listService = new ListService()
this.loadList()
@ -129,15 +120,20 @@
// call again the method if the route changes
'$route': 'loadList'
},
computed: {
linkSharingEnabled() {
return this.$store.state.config.linkSharingEnabled
},
userIsAdmin() {
return this.list.owner && this.list.owner.id === this.$store.state.auth.info.id
},
},
methods: {
loadList() {
let list = new ListModel({id: this.$route.params.id})
this.listService.get(list)
.then(r => {
this.$set(this, 'list', r)
if (r.owner.id === this.user.infos.id) {
this.userIsAdmin = true
}
// This will trigger the dynamic loading of components once we actually have all the data to pass to them
this.manageTeamsComponent = 'manageSharing'
this.manageUsersComponent = 'manageSharing'
@ -149,15 +145,7 @@
submit() {
this.listService.update(this.list)
.then(r => {
// Update the list in the parent
for (const n in this.$parent.namespaces) {
let lists = this.$parent.namespaces[n].lists
for (const l in lists) {
if (lists[l].id === r.id) {
this.$set(this.$parent.namespaces[n].lists, l, r)
}
}
}
this.$store.commit('namespaces/setListInNamespaceById', r)
this.success({message: 'The list was successfully updated.'}, this)
})
.catch(e => {

View File

@ -32,10 +32,10 @@
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import ListService from '../../services/list'
import ListModel from '../../models/list'
import {IS_FULLPAGE} from '../../store/mutation-types'
export default {
name: "NewList",
@ -46,16 +46,10 @@
listService: ListService,
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
},
created() {
this.list = new ListModel()
this.listService = new ListService()
this.$parent.setFullPage();
this.$store.commit(IS_FULLPAGE, true)
},
methods: {
newList() {
@ -68,7 +62,8 @@
this.list.namespaceId = this.$route.params.id
this.listService.create(this.list)
.then(response => {
this.$parent.loadNamespaces()
response.namespaceId = this.list.namespaceId
this.$store.commit('namespaces/addListToNamespace', response)
this.success({message: 'The list was successfully created.'}, this)
router.push({name: 'list.index', params: {listId: response.id}})
})

View File

@ -22,12 +22,10 @@
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import ListModel from '../../models/list'
import ListService from '../../services/list'
import authType from '../../models/authTypes'
export default {
data() {
@ -37,12 +35,6 @@
listLoaded: 0,
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated && auth.user.infos.type !== authType.LINK_SHARE) {
router.push({name: 'home'})
}
},
created() {
this.listService = new ListService()
this.list = new ListModel()

View File

@ -3,9 +3,9 @@
<h1>Import your data from other services to Vikunja</h1>
<p>Click on the logo of one of the third-party services below to get started.</p>
<div class="migration-services-overview">
<router-link :to="{name: 'migrateWunderlist'}">
<img src="/images/migration/wunderlist.png" alt="Wunderlist"/>
Wunderlist
<router-link :to="{name: 'migrate.'+m}" v-for="m in availableMigrators" :key="m">
<img :src="`/images/migration/${m}.png`" :alt="m"/>
{{ m }}
</router-link>
</div>
</div>
@ -13,6 +13,11 @@
<script>
export default {
name: 'migrate'
name: 'migrate',
computed: {
availableMigrators() {
return this.$store.state.config.availableMigrators
},
},
}
</script>

View File

@ -83,7 +83,6 @@
import verte from 'verte'
import 'verte/dist/verte.css'
import auth from '../../auth'
import router from '../../router'
import manageSharing from '../sharing/userTeam'
@ -96,13 +95,11 @@
data() {
return {
namespaceService: NamespaceService,
userIsAdmin: false,
manageUsersComponent: '',
manageTeamsComponent: '',
namespace: NamespaceModel,
showDeleteModal: false,
user: auth.user,
}
},
components: {
@ -111,11 +108,6 @@
verte,
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
this.namespace.id = this.$route.params.id
},
created() {
@ -127,15 +119,17 @@
// call again the method if the route changes
'$route': 'loadNamespace'
},
computed: {
userIsAdmin() {
return this.namespace.owner && this.namespace.owner.id === this.$store.state.auth.info.id
},
},
methods: {
loadNamespace() {
let namespace = new NamespaceModel({id: this.$route.params.id})
this.namespaceService.get(namespace)
.then(r => {
this.$set(this, 'namespace', r)
if (r.owner.id === this.user.infos.id) {
this.userIsAdmin = true
}
// This will trigger the dynamic loading of components once we actually have all the data to pass to them
this.manageTeamsComponent = 'manageSharing'
this.manageUsersComponent = 'manageSharing'
@ -148,12 +142,7 @@
this.namespaceService.update(this.namespace)
.then(r => {
// Update the namespace in the parent
for (const n in this.$parent.namespaces) {
if (this.$parent.namespaces[n].id === r.id) {
r.lists = this.$parent.namespaces[n].lists
this.$set(this.$parent.namespaces, n, r)
}
}
this.$store.commit('namespaces/setNamespaceById', r)
this.success({message: 'The namespace was successfully updated.'}, this)
})
.catch(e => {

View File

@ -34,10 +34,10 @@
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import NamespaceModel from "../../models/namespace";
import NamespaceService from "../../services/namespace";
import {IS_FULLPAGE} from '../../store/mutation-types'
export default {
name: "NewNamespace",
@ -48,16 +48,10 @@
namespaceService: NamespaceService,
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
},
created() {
this.namespace = new NamespaceModel()
this.namespaceService = new NamespaceService()
this.$parent.setFullPage();
this.$store.commit(IS_FULLPAGE, true)
},
methods: {
newNamespace() {
@ -68,10 +62,10 @@
this.showError = false
this.namespaceService.create(this.namespace)
.then(() => {
this.$parent.loadNamespaces()
.then(r => {
this.$store.commit('namespaces/addNamespace', r)
this.success({message: 'The namespace was successfully created.'}, this)
router.push({name: 'home'})
router.back()
})
.catch(e => {
this.error(e, this)

View File

@ -75,7 +75,7 @@
</template>
</td>
<td class="actions">
<button @click="linkIdToDelete = s.id; showDeleteModal = true" class="button is-danger icon-only">
<button @click="() => {linkIdToDelete = s.id; showDeleteModal = true}" class="button is-danger icon-only">
<span class="icon">
<icon icon="trash-alt"/>
</span>
@ -106,6 +106,7 @@
import LinkShareModel from '../../models/linkShare'
import copy from 'copy-to-clipboard'
import {mapState} from 'vuex'
export default {
name: 'linkSharing',
@ -138,6 +139,9 @@
this.load()
}
},
computed: mapState({
frontendUrl: state => state.config.frontendUrl,
}),
methods: {
load() {
// If listId == 0 the list on the calling component wasn't already loaded, so we just bail out here
@ -183,7 +187,7 @@
copy(text)
},
getShareLink(hash) {
return this.$config.frontend_url + 'share/' + hash + '/auth'
return this.frontendUrl + 'share/' + hash + '/auth'
},
},
}

View File

@ -9,7 +9,6 @@
</template>
<script>
import auth from '../../auth'
import router from '../../router'
export default {
@ -25,7 +24,7 @@
},
methods: {
auth() {
auth.linkShareAuth(this.$route.params.share)
this.$store.dispatch('auth/linkShareAuth', this.$route.params.share)
.then((r) => {
this.loading = false
router.push({name: 'list.list', params: {listId: r.list_id}})

View File

@ -42,7 +42,7 @@
<template v-if="shareType === 'user'">
<td>{{s.username}}</td>
<td>
<template v-if="s.id === currentUser.id">
<template v-if="s.id === userInfo.id">
<b class="is-success">You</b>
</template>
</td>
@ -105,8 +105,8 @@
</template>
<script>
import auth from '../../auth'
import multiselect from 'vue-multiselect'
import {mapState} from 'vuex'
import UserNamespaceService from '../../services/userNamespace'
import UserNamespaceModel from '../../models/userNamespace'
@ -156,7 +156,6 @@
rights: rights,
selectedRight: rights.READ,
currentUser: auth.user.infos,
typeString: '',
sharables: [], // This holds either teams or users who this namepace or list is shared with
showDeleteModal: false,
@ -165,6 +164,9 @@
components: {
multiselect
},
computed: mapState({
userInfo: state => state.auth.info
}),
created() {
if (this.shareType === 'user') {

View File

@ -39,7 +39,7 @@
</div>
<div class="media comment">
<figure class="media-left">
<img class="image is-avatar" :src="user.infos.getAvatarUrl(48)" alt="" width="48" height="48"/>
<img class="image is-avatar" :src="userAvatar" alt="" width="48" height="48"/>
</figure>
<div class="media-content">
<div class="form">
@ -67,7 +67,6 @@
<script>
import TaskCommentService from '../../../services/taskComment'
import TaskCommentModel from '../../../models/taskComment'
import auth from '../../../auth'
export default {
name: 'comments',
@ -80,7 +79,6 @@
data() {
return {
comments: [],
user: auth.user,
showDeleteModal: false,
commentToDelete: TaskCommentModel,
@ -107,6 +105,11 @@
this.loadComments()
}
},
computed: {
userAvatar() {
return this.$store.state.auth.info.getAvatarUrl(48)
},
},
methods: {
loadComments() {
this.taskCommentService.getAll({taskId: this.taskId})

View File

@ -107,7 +107,7 @@
<tr v-for="m in team.members" :key="m.id">
<td>{{m.username}}</td>
<td>
<template v-if="m.id === user.infos.id">
<template v-if="m.id === userInfo.id">
<b class="is-success">You</b>
</template>
</td>
@ -127,7 +127,7 @@
</td>
<td class="actions" v-if="userIsAdmin">
<button @click="toggleUserType(m)" class="button buttonright is-primary"
v-if="m.id !== user.infos.id">
v-if="m.id !== userInfo.id">
Make
<template v-if="!m.admin">
Admin
@ -137,7 +137,7 @@
</template>
</button>
<button @click="() => {member = m; showUserDeleteModal = true}" class="button is-danger"
v-if="m.id !== user.infos.id">
v-if="m.id !== userInfo.id">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
@ -173,9 +173,9 @@
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import multiselect from 'vue-multiselect'
import {mapState} from 'vuex'
import TeamService from '../../services/team'
import TeamModel from '../../models/team'
@ -196,7 +196,6 @@
showDeleteModal: false,
showUserDeleteModal: false,
user: auth.user,
userIsAdmin: false,
newMember: UserModel,
@ -209,12 +208,6 @@
components: {
multiselect,
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
},
created() {
this.teamService = new TeamService()
this.teamMemberService = new TeamMemberService()
@ -225,9 +218,11 @@
// call again the method if the route changes
'$route': 'loadTeam'
},
computed: mapState({
userInfo: state => state.auth.info,
}),
methods: {
loadTeam() {
// this.member = new TeamMemberModel({teamId: this.teamId})
this.team = new TeamModel({id: this.teamId})
this.teamService.get(this.team)
.then(response => {
@ -235,7 +230,7 @@
let members = response.members
for (const m in members) {
members[m].teamId = this.teamId
if (members[m].id === this.user.infos.id && members[m].admin) {
if (members[m].id === this.userInfo.id && members[m].admin) {
this.userIsAdmin = true
}
}

View File

@ -18,8 +18,6 @@
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import TeamService from '../../services/team'
export default {
@ -30,12 +28,6 @@
teams: [],
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
},
created() {
this.teamService = new TeamService()
this.loadTeams()

View File

@ -32,10 +32,10 @@
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import TeamModel from '../../models/team'
import TeamService from '../../services/team'
import {IS_FULLPAGE} from '../../store/mutation-types'
export default {
name: "NewTeam",
@ -46,16 +46,10 @@
showError: false,
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
},
created() {
this.teamService = new TeamService()
this.team = new TeamModel()
this.$parent.setFullPage();
this.$store.commit(IS_FULLPAGE, true)
},
methods: {
newTeam() {

View File

@ -9,31 +9,61 @@
<div class="field">
<label class="label" for="username">Username</label>
<div class="control">
<input v-focus type="text" id="username" class="input" name="username" placeholder="e.g. frederick" ref="username" required/>
<input
v-focus type="text"
id="username"
class="input"
name="username"
placeholder="e.g. frederick"
ref="username"
required
/>
</div>
</div>
<div class="field">
<label class="label" for="password">Password</label>
<div class="control">
<input type="password" class="input" id="password" name="password" placeholder="e.g. ••••••••••••" ref="password" required/>
<input
type="password"
class="input"
id="password"
name="password"
placeholder="e.g. ••••••••••••"
ref="password"
required
/>
</div>
</div>
<div class="field" v-if="needsTotpPasscode">
<label class="label" for="totpPasscode">Two Factor Authentication Code</label>
<div class="control">
<input type="text" class="input" id="totpPasscode" placeholder="e.g. 123456" ref="totpPasscode" required v-focus/>
<input
type="text"
class="input"
id="totpPasscode"
placeholder="e.g. 123456"
ref="totpPasscode"
required
v-focus
/>
</div>
</div>
<div class="field is-grouped">
<div class="control is-expanded">
<button type="submit" class="button is-primary" v-bind:class="{ 'is-loading': loading}">Login
</button>
<router-link :to="{ name: 'register' }" class="button" v-if="registrationEnabled">Register
</router-link>
</div>
<div class="control">
<button type="submit" class="button is-primary" v-bind:class="{ 'is-loading': loading}">Login</button>
<router-link :to="{ name: 'register' }" class="button">Register</router-link>
<router-link :to="{ name: 'getPasswordReset' }" class="reset-password-link">Reset your password</router-link>
<router-link :to="{ name: 'getPasswordReset' }" class="reset-password-link">Reset your
password
</router-link>
</div>
</div>
<div class="notification is-danger" v-if="errorMsg">
{{ errorMsg }}
<div class="notification is-danger" v-if="errorMessage">
{{ errorMessage }}
</div>
</form>
</div>
@ -41,18 +71,17 @@
</template>
<script>
import auth from '../../auth'
import {mapState} from 'vuex'
import router from '../../router'
import {HTTP} from '../../http-common'
import message from '../../message'
import {ERROR_MESSAGE, LOADING} from '../../store/mutation-types'
export default {
data() {
return {
errorMsg: '',
confirmedEmailSuccess: false,
loading: false,
needsTotpPasscode: false,
}
},
beforeMount() {
@ -69,19 +98,25 @@
})
.catch(e => {
cancel()
this.errorMsg = e.response.data.message
this.$store.commit(ERROR_MESSAGE, e.response.data.message)
})
}
// Check if the user is already logged in, if so, redirect him to the homepage
if (auth.user.authenticated) {
if (this.authenticated) {
router.push({name: 'home'})
}
},
computed: mapState({
registrationEnabled: state => state.config.registrationEnabled,
loading: LOADING,
errorMessage: ERROR_MESSAGE,
needsTotpPasscode: state => state.auth.needsTotpPasscode,
authenticated: state => state.auth.authenticated,
}),
methods: {
submit() {
this.loading = true
this.errorMsg = ''
this.$store.commit(ERROR_MESSAGE, '')
// 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
@ -90,11 +125,15 @@
password: this.$refs.password.value,
}
if(this.needsTotpPasscode) {
if (this.needsTotpPasscode) {
credentials.totpPasscode = this.$refs.totpPasscode.value
}
auth.login(this, credentials, 'home')
this.$store.dispatch('auth/login', credentials)
.then(() => {
router.push({name: 'home'})
})
.catch(() => {})
}
}
}
@ -105,7 +144,7 @@
margin: 0 0.4em 0 0;
}
.reset-password-link{
.reset-password-link {
display: inline-block;
padding-top: 5px;
}

View File

@ -37,8 +37,8 @@
<div class="notification is-info" v-if="loading">
Loading...
</div>
<div class="notification is-danger" v-if="errorMsg !== ''">
{{ errorMsg }}
<div class="notification is-danger" v-if="errorMessage !== ''">
{{ errorMessage }}
</div>
</form>
</div>
@ -46,8 +46,9 @@
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import {mapState} from 'vuex'
import {ERROR_MESSAGE, LOADING} from '../../store/mutation-types'
export default {
data() {
@ -58,35 +59,37 @@
password: '',
password2: '',
},
errorMsg: '',
loading: false
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (auth.user.authenticated) {
if (this.authenticated) {
router.push({name: 'home'})
}
},
computed: mapState({
authenticated: state => state.auth.authenticated,
loading: LOADING,
errorMessage: ERROR_MESSAGE,
}),
methods: {
submit() {
this.loading = true
this.errorMsg = ''
this.$store.commit(LOADING, true)
this.$store.commit(ERROR_MESSAGE, '')
if (this.credentials.password2 !== this.credentials.password) {
this.loading = false
this.errorMsg = 'Passwords don\'t match.'
this.$store.commit(ERROR_MESSAGE, 'Passwords don\'t match.')
this.$store.commit(LOADING, false)
return
}
let credentials = {
const credentials = {
username: this.credentials.username,
email: this.credentials.email,
password: this.credentials.password
}
auth.register(this, credentials, 'home')
this.$store.dispatch('auth/register', credentials)
}
}
}

View File

@ -1,16 +0,0 @@
import {HTTP} from './http-common'
export default {
config: null,
getConfig() {
return this.config
},
initConfig() {
return HTTP.get('info')
.then(r => {
this.config = r.data
})
}
}

View File

@ -1,7 +1,6 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import auth from './auth'
// Register the modal
import Modal from './components/modal/Modal'
@ -20,12 +19,6 @@ Vue.config.productionTip = false
import Notifications from 'vue-notification'
Vue.use(Notifications)
import config from './config'
config.initConfig()
.then(() => {
Vue.prototype.$config = config.getConfig()
})
// Icons
import { library } from '@fortawesome/fontawesome-svg-core'
import { faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
@ -138,9 +131,6 @@ Vue.directive('focus', {
}
})
// Check the user's auth status when the app starts
auth.checkAuth()
// Mixins
import message from './message'
import {format, formatDistance} from 'date-fns'
@ -165,7 +155,11 @@ Vue.mixin({
}
})
// Vuex
import {store} from './store'
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

View File

@ -2,7 +2,6 @@
import { register } from 'register-service-worker'
import swEvents from './ServiceWorker/events'
import auth from './auth'
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}sw.js`, {
@ -45,7 +44,7 @@ if(navigator && navigator.serviceWorker) {
if(action === 'getBearerToken') {
console.debug('Token request from sw');
port.postMessage({
authToken: auth.getToken(),
authToken: localStorage.getItem('token'),
})
} else {
console.error('Unknown event', event);

View File

@ -197,7 +197,7 @@ export default new Router({
},
{
path: '/migrate/wunderlist',
name: 'migrateWunderlist',
name: 'migrate.wunderlist',
component: WunderlistMigrationComponent,
},
{

36
src/store/index.js Normal file
View File

@ -0,0 +1,36 @@
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import config from './modules/config'
import auth from './modules/auth'
import namespaces from './modules/namespaces'
import {ERROR_MESSAGE, IS_FULLPAGE, LOADING, ONLINE} from './mutation-types'
export const store = new Vuex.Store({
modules: {
config,
auth,
namespaces,
},
state: {
loading: false,
errorMessage: '',
online: true,
isFullpage: false,
},
mutations: {
[LOADING](state, loading) {
state.loading = loading
},
[ERROR_MESSAGE](state, error) {
state.errorMessage = error
},
[ONLINE](state, online) {
state.online = online
},
[IS_FULLPAGE](state, fullpage) {
state.isFullpage = fullpage
}
},
})

148
src/store/modules/auth.js Normal file
View File

@ -0,0 +1,148 @@
import {HTTP} from '../../http-common'
import {ERROR_MESSAGE, LOADING} from "../mutation-types";
import UserModel from "../../models/user";
export default {
namespaced: true,
state: () => ({
authenticated: false,
isLinkShareAuth: false,
info: {},
needsTotpPasscode: false,
}),
mutations: {
info(state, info) {
state.info = info
},
authenticated(state, authenticated) {
state.authenticated = authenticated
},
isLinkShareAuth(state, is) {
state.isLinkShareAuth = is
},
needsTotpPasscode(state, needs) {
state.needsTotpPasscode = needs
},
},
actions: {
// Logs a user in with a set of credentials.
login(ctx, credentials) {
ctx.commit(LOADING, true, {root: true})
// Delete an eventually preexisting old token
localStorage.removeItem('token')
const data = {
username: credentials.username,
password: credentials.password
}
if(credentials.totpPasscode) {
data.totp_passcode = credentials.totpPasscode
}
return HTTP.post('login', data)
.then(response => {
// Save the token to local storage for later use
localStorage.setItem('token', response.data.token)
// Tell others the user is autheticated
ctx.commit('isLinkShareAuth', false)
ctx.dispatch('checkAuth')
return Promise.resolve()
})
.catch(e => {
if (e.response) {
if (e.response.data.code === 1017 && !credentials.totpPasscode) {
ctx.commit('needsTotpPasscode', true)
return Promise.reject()
}
let errorMsg = e.response.data.message
if (e.response.status === 401) {
errorMsg = 'Wrong username or password.'
}
ctx.commit(ERROR_MESSAGE, errorMsg, {root: true})
}
return Promise.reject()
})
.finally(() => {
ctx.commit(LOADING, false, {root: true})
})
},
// Registers a new user and logs them in.
// Not sure if this is the right place to put the logic in, maybe a seperate js component would be better suited.
register(ctx, credentials) {
return HTTP.post('register', {
username: credentials.username,
email: credentials.email,
password: credentials.password
})
.then(() => {
return ctx.dispatch('login', credentials)
})
.catch(e => {
if (e.response) {
ctx.commit(ERROR_MESSAGE, e.response.data.message, {root: true})
}
return Promise.reject()
})
.finally(() => {
ctx.commit(LOADING, false, {root: true})
})
},
linkShareAuth(ctx, hash) {
return HTTP.post('/shares/' + hash + '/auth')
.then(r => {
localStorage.setItem('token', r.data.token)
ctx.dispatch('checkAuth')
return Promise.resolve(r.data)
}).catch(e => {
return Promise.reject(e)
})
},
// Populates user information from jwt token saved in local storage in store
checkAuth(ctx) {
const jwt = localStorage.getItem('token')
let authenticated = false
if (jwt) {
const base64 = jwt
.split('.')[1]
.replace('-', '+')
.replace('_', '/')
const info = new UserModel(JSON.parse(window.atob(base64)))
const ts = Math.round((new Date()).getTime() / 1000)
if (info.exp >= ts) {
authenticated = true
}
ctx.commit('info', info)
}
ctx.commit('authenticated', authenticated)
},
// Renews the api token and saves it to local storage
renewToken(ctx) {
if (!ctx.state.authenticated) {
return
}
HTTP.post('user/token', null, {
headers: {
Authorization: 'Bearer ' + localStorage.getItem('token'),
}
})
.then(r => {
localStorage.setItem('token', r.data.token)
ctx.dispatch('checkAuth')
})
.catch(e => {
// eslint-disable-next-line
console.log('Error renewing token: ', e)
})
},
logout(ctx) {
localStorage.removeItem('token')
ctx.dispatch('checkAuth')
}
},
}

View File

@ -0,0 +1,37 @@
import {CONFIG} from '../mutation-types'
import {HTTP} from '../../http-common'
export default {
namespaced: true,
state: () => ({
// These are the api defaults.
version: '',
frontendUrl: '',
motd: '',
linkSharingEnabled: true,
maxFileSize: '20MB',
registrationEnabled: true,
availableMigrators: [],
taskAttachmentsEnabled: true,
}),
mutations: {
[CONFIG](state, config) {
state.version = config.version
state.frontendUrl = config.frontend_url
state.motd = config.motd
state.linkSharingEnabled = config.link_sharing_enabled
state.maxFileSize = config.max_file_size
state.registrationEnabled = config.registration_enabled
state.availableMigrators = config.available_migrators
state.taskAttachmentsEnabled = config.task_attachments_enabled
},
},
actions: {
update(ctx) {
HTTP.get('info')
.then(r => {
ctx.commit(CONFIG, r.data)
})
},
},
}

View File

@ -0,0 +1,63 @@
import Vue from 'vue'
import NamespaceService from '../../services/namespace'
export default {
namespaced: true,
state: () => ({
namespaces: [],
}),
mutations: {
namespaces(state, namespaces) {
state.namespaces = namespaces
},
setNamespaceById(state, namespace) {
for (const n in state.namespaces) {
if (state.namespaces[n].id === namespace.id) {
namespace.lists = state.namespaces[n].lists
Vue.set(state.namespaces, n, namespace)
return
}
}
},
setListInNamespaceById(state, list) {
for (const n in state.namespaces) {
// We don't have the namespace id on the list which means we need to loop over all lists until we find it.
// FIXME: Not ideal at all - we should fix that at the api level.
for (const l in state.namespaces[n].lists) {
if (state.namespaces[n].lists[l].id === list.id) {
const namespace = state.namespaces[n]
namespace.lists[l] = list
Vue.set(state.namespaces, n, namespace)
return
}
}
}
},
addNamespace(state, namespace) {
state.namespaces.push(namespace)
},
addListToNamespace(state, list) {
for (const n in state.namespaces) {
if (state.namespaces[n].id === list.namespaceId) {
state.namespaces[n].lists.push(list)
return
}
}
},
},
actions: {
loadNamespaces(ctx) {
const namespaceService = new NamespaceService()
// We always load all namespaces and filter them on the frontend
return namespaceService.getAll({}, {is_archived: true})
.then(r => {
ctx.commit('namespaces', r)
return Promise.resolve()
})
.catch(e => {
return Promise.reject(e)
})
},
},
}

View File

@ -0,0 +1,7 @@
export const LOADING = 'loading'
export const ERROR_MESSAGE = 'errorMessage'
export const ONLINE = 'online'
export const IS_FULLPAGE = 'isFullpage'
export const CONFIG = 'config'
export const AUTH = 'auth'

View File

@ -4,6 +4,7 @@
a {
display: inline-block;
width: 100px;
text-transform: capitalize;
img {
display: block;

View File

@ -12403,6 +12403,11 @@ vue@2.6.11, vue@^2.6.11:
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.11.tgz#76594d877d4b12234406e84e35275c6d514125c5"
integrity sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ==
vuex@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.3.0.tgz#665b4630ea1347317139fcc5cb495aab3ec5e513"
integrity sha512-1MfcBt+YFd20DPwKe0ThhYm1UEXZya4gVKUvCy7AtS11YAOUR+9a6u4fsv1Rr6ePZCDNxW/M1zuIaswp6nNv8Q==
watch@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/watch/-/watch-1.0.2.tgz#340a717bde765726fa0aa07d721e0147a551df0c"