Vuex (#126)
continuous-integration/drone/push Build is passing Details

Merge branch 'master' into feature/vuex

Cleanup

Move namespaces handling to store

Move fullpage setting to store

Move online/offline handling to store

Remove debug log

Fix loading namepaces

Rename errorMsg

Handle registering through the store

Use store to determine wether or not a user is authenticated on login page

Use store in edit team

Use store to get the current user's avatar

Use store to figure out if the current user belongs to a team

Use store to figure out if the current user is list admin

Use store to get user info when listing labels

Use store to get username on home

Use store to figure out if the user is namespace admin

Use the store for configuration

Use the store to check if a user is authenticated everywhere

Only renew token if the user is logged in

Fix renewing auth from jwt token

Move logout to store

Move renew token to store

Move log in to store

Only show enabled migrators

Only show registration button if registration is enabled

Put all config information from the backend in the store

Remove old config handler

Move config state to seperate module

Add vuex and get the motd through it

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: #126
This commit is contained in:
konrad 2020-05-08 18:43:51 +00:00
parent 2ba6b7ef3a
commit 5724b98358
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"