user management for the webui:

- some UI elements are hidden for non-admin users
- server settings screen to administrate users
- account settings screen to update user's password
This commit is contained in:
Gauthier Roebroeck 2019-10-16 11:04:33 +08:00
parent 76521fe111
commit f5e8698e57
15 changed files with 863 additions and 23 deletions

View file

@ -15577,6 +15577,11 @@
"resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.1.tgz",
"integrity": "sha512-ER5moSbLZuNSMBFnEBVGhQ1uCBNJslH9W/Dw2W7GZN23UQA69uapP5GTT9Vm8Trc0PzBSVt6LzF3hGjmv41xcg=="
},
"vuex-router-sync": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/vuex-router-sync/-/vuex-router-sync-5.0.0.tgz",
"integrity": "sha512-Mry2sO4kiAG64714X1CFpTA/shUH1DmkZ26DFDtwoM/yyx6OtMrc+MxrU+7vvbNLO9LSpgwkiJ8W+rlmRtsM+w=="
},
"w3c-hr-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz",

View file

@ -17,7 +17,8 @@
"vue-router": "^3.0.3",
"vuelidate": "^0.7.4",
"vuetify": "^2.1.2",
"vuex": "^3.0.1"
"vuex": "^3.0.1",
"vuex-router-sync": "^5.0.0"
},
"devDependencies": {
"@types/jest": "^23.1.4",

View file

@ -0,0 +1,59 @@
<template>
<v-container fluid>
<v-row>
<span class="headline">Account Settings</span>
</v-row>
<v-row align="center">
<v-col cols="2">Email</v-col>
<v-col>
<v-text-field readonly v-model="me.email"></v-text-field>
</v-col>
</v-row>
<v-row align="center">
<v-col cols="2">Roles</v-col>
<v-col>
<v-chip-group>
<v-chip v-for="role in me.roles" :key="role">{{ role }}</v-chip>
</v-chip-group>
</v-col>
</v-row>
<v-row>
<v-col>
<v-btn color="primary"
@click.prevent="modalPasswordChange = true"
>Change password
</v-btn>
</v-col>
</v-row>
<password-change-dialog v-model="modalPasswordChange"
:user="me"
></password-change-dialog>
</v-container>
</template>
<script lang="ts">
import PasswordChangeDialog from '@/components/PasswordChangeDialog.vue'
import Vue from 'vue'
export default Vue.extend({
name: 'AccountSettings',
components: { PasswordChangeDialog },
data: () => {
return {
modalPasswordChange: false,
newPassword: ''
}
},
computed: {
me (): UserDto {
return this.$store.state.komgaUsers.me
}
}
})
</script>
<style scoped>
</style>

View file

@ -0,0 +1,174 @@
<template>
<div>
<v-dialog v-model="modalAddUser"
:fullscreen="this.$vuetify.breakpoint.xsOnly"
:hide-overlay="this.$vuetify.breakpoint.xsOnly"
max-width="450"
>
<v-card>
<v-toolbar class="hidden-sm-and-up">
<v-btn icon @click="dialogCancel">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>{{ dialogTitle }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items>
<v-btn text color="primary" @click="dialogConfirm">{{ confirmText }}</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-card-title class="hidden-xs-only">{{ dialogTitle }}</v-card-title>
<v-card-text>
<form novalidate>
<v-container fluid>
<v-row>
<v-col>
<v-text-field v-model="form.email"
label="Email"
:error-messages="getErrors('email')"
@blur="$v.form.email.$touch()"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field v-model="form.password"
label="Password"
autocomplete="off"
:type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword = !showPassword"
:error-messages="getErrors('password')"
@input="$v.form.password.$touch()"
@blur="$v.form.password.$touch()"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<p>Roles</p>
<v-checkbox
v-model="form.admin"
label="Administrator"
></v-checkbox>
</v-col>
</v-row>
</v-container>
</form>
</v-card-text>
<v-card-actions class="hidden-xs-only">
<v-spacer></v-spacer>
<v-btn text @click="dialogCancel">Cancel</v-btn>
<v-btn text class="primary--text" @click="dialogConfirm">{{ confirmText }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar"
bottom
color="error"
>
{{ snackText }}
<v-btn
text
@click="snackbar = false"
>
Close
</v-btn>
</v-snackbar>
</div>
</template>
<script lang="js">
import Vue from 'vue'
import { email, required } from 'vuelidate/lib/validators'
export default Vue.extend({
name: 'AddUserDialog',
data: () => {
return {
modalAddUser: true,
showPassword: false,
snackbar: false,
snackText: '',
dialogTitle: 'Add User',
confirmText: 'Add',
form: {
email: '',
password: '',
admin: false
},
validationFieldNames: new Map([])
}
},
watch: {
modalAddUser (val) {
!val && this.dialogCancel()
}
},
validations: {
form: {
email: { required, email },
password: { required }
}
},
methods: {
getErrors (fieldName) {
const errors = []
const field = this.$v.form[fieldName]
if (field && field.$invalid && field.$dirty) {
const properName = this.validationFieldNames.has(fieldName)
? this.validationFieldNames.get(fieldName) : fieldName.charAt(0).toUpperCase() + fieldName.substring(1)
if (!field.required) errors.push(`${properName} is required.`)
if (!field.email) errors.push(`${properName} must be a valid email address.`)
}
return errors
},
showSnack (message) {
this.snackText = message
this.snackbar = true
},
dialogCancel () {
this.$router.push({ name: 'settings-users' })
},
dialogConfirm () {
this.addUser()
},
validateUser () {
this.$v.$touch()
if (!this.$v.$invalid) {
return {
email: this.form.email,
password: this.form.password,
roles: this.form.admin ? ['ADMIN'] : []
}
}
return null
},
async addUser () {
const user = this.validateUser()
if (user) {
try {
await this.$store.dispatch('postUser', user)
this.$router.push({ name: 'settings-users' })
} catch (e) {
this.showSnack(e.message)
}
}
}
}
})
</script>
<style scoped>
</style>

View file

@ -0,0 +1,111 @@
<template>
<div>
<v-dialog v-model="modal"
max-width="450"
>
<v-card>
<v-card-title>Delete User</v-card-title>
<v-card-text>
<v-container fluid>
<v-row>
<v-col>The user <b>{{ user.email }}</b> will be deleted from this server. This <b>cannot</b> be undone.
Continue ?
</v-col>
</v-row>
<v-row>
<v-col>
<v-checkbox v-model="confirmDelete" color="red">
<template v-slot:label>
Yes, delete the user "{{ user.email }}"
</template>
</v-checkbox>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="dialogCancel">Cancel</v-btn>
<v-btn text class="red--text"
@click="dialogConfirm"
:disabled="!confirmDelete"
>Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar"
bottom
color="error"
>
{{ snackText }}
<v-btn
text
@click="snackbar = false"
>
Close
</v-btn>
</v-snackbar>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'DeleteUserDialog',
data: () => {
return {
confirmDelete: false,
snackbar: false,
snackText: '',
modal: false
}
},
props: {
value: Boolean,
user: {
type: Object,
required: true
}
},
watch: {
value (val) {
this.modal = val
},
modal (val) {
!val && this.dialogCancel()
}
},
methods: {
dialogCancel () {
this.$emit('input', false)
this.confirmDelete = false
},
dialogConfirm () {
this.deleteUser()
this.$emit('input', false)
},
showSnack (message: string) {
this.snackText = message
this.snackbar = true
},
async deleteUser () {
try {
await this.$store.dispatch('deleteUser', this.user)
} catch (e) {
this.showSnack(e.message)
}
}
}
})
</script>
<style scoped>
</style>

View file

@ -0,0 +1,167 @@
<template>
<div>
<v-dialog v-model="modal"
max-width="450"
>
<v-card>
<v-card-title>Change password</v-card-title>
<v-card-text>
<form novalidate>
<v-container fluid>
<v-row>
<v-col>
<v-text-field label="New password"
v-model="form.newPassword"
autocomplete="off"
:type="showPassword1 ? 'text' : 'password'"
:append-icon="showPassword1 ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword1 = !showPassword1"
:error-messages="getErrors('newPassword')"
@input="$v.form.newPassword.$touch()"
@blur="$v.form.newPassword.$touch()"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field label="Repeat new password"
v-model="form.repeatPassword"
autocomplete="off"
:type="showPassword2 ? 'text' : 'password'"
:append-icon="showPassword2 ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword2 = !showPassword2"
:error-messages="getErrors('repeatPassword')"
@input="$v.form.repeatPassword.$touch()"
@blur="$v.form.repeatPassword.$touch()"
></v-text-field>
</v-col>
</v-row>
</v-container>
</form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="dialogCancel">Cancel</v-btn>
<v-btn text class="primary--text"
@click="dialogConfirm"
>Change password
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar"
bottom
color="error"
>
{{ snackText }}
<v-btn
text
@click="snackbar = false"
>
Close
</v-btn>
</v-snackbar>
</div>
</template>
<script lang="js">
import Vue from 'vue'
import { required, sameAs } from 'vuelidate/lib/validators'
export default Vue.extend({
name: 'PasswordChangeDialog',
data: () => {
return {
modal: false,
showPassword1: false,
showPassword2: false,
snackbar: false,
snackText: '',
form: {
newPassword: '',
repeatPassword: ''
},
validationFieldNames: new Map([
['newPassword', 'Password'],
['confirmPassword', 'Password confirmation']
])
}
},
props: {
value: Boolean,
user: {
type: Object,
required: true
}
},
watch: {
value (val) {
this.modal = val
},
modal (val) {
!val && this.dialogCancel()
}
},
validations: {
form: {
newPassword: { required },
repeatPassword: { sameAsPassword: sameAs('newPassword') }
}
},
methods: {
getErrors (fieldName) {
const errors = []
const field = this.$v.form[fieldName]
if (field && field.$invalid && field.$dirty) {
if (fieldName === 'newPassword') {
if (!field.required) errors.push(`New password is required.`)
}
if (fieldName === 'repeatPassword') {
if (!field.sameAsPassword) errors.push(`Passwords must be identical.`)
}
}
return errors
},
showSnack (message) {
this.snackText = message
this.snackbar = true
},
formReset () {
this.form.newPassword = ''
this.form.repeatPassword = ''
this.showPassword1 = false
this.showPassword2 = false
this.$v.$reset()
},
dialogCancel () {
this.$emit('input', false)
this.formReset()
},
dialogConfirm () {
this.$v.$touch()
if (!this.$v.$invalid) {
this.updatePassword()
this.$emit('input', false)
this.formReset()
}
},
async updatePassword () {
try {
await this.$store.dispatch('updateMyPassword', { password: this.form.newPassword })
} catch (e) {
this.showSnack(e.message)
}
}
}
})
</script>
<style scoped>
</style>

View file

@ -0,0 +1,90 @@
<template>
<v-container fluid>
<v-row>
<span class="headline">Users</span>
</v-row>
<v-row>
<v-col cols="12" md="4" lg="4" xl="4">
<div style="position: relative">
<v-list
elevation="3"
>
<div v-for="(u, index) in users" :key="index">
<v-list-item
>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-list-item-icon v-on="on">
<v-icon v-if="u.roles.includes('ADMIN')" color="red">mdi-account-star</v-icon>
<v-icon v-else>mdi-account</v-icon>
</v-list-item-icon>
</template>
<span>{{ u.roles.includes('ADMIN') ? 'Administrator' : 'User' }}</span>
</v-tooltip>
<v-list-item-content>
<v-list-item-title>
{{ u.email }}
</v-list-item-title>
</v-list-item-content>
<v-list-item-action>
<v-btn icon @click="promptDeleteUser(u)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
<v-divider v-if="index !== users.length-1"></v-divider>
</div>
</v-list>
<v-btn fab absolute bottom right color="primary"
class="mr-6"
:to="{name: 'settings-users-add'}">
<v-icon>mdi-plus</v-icon>
</v-btn>
<delete-user-dialog v-model="modalDeleteUser"
:user="userToDelete">
</delete-user-dialog>
<router-view></router-view>
</div>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts">
import DeleteUserDialog from '@/components/DeleteUserDialog.vue'
import Vue from 'vue'
export default Vue.extend({
name: 'SettingsUsers',
components: { DeleteUserDialog },
data: () => ({
modalDeleteUser: false,
modalAddUser: false,
userToDelete: {} as UserDto
}),
computed: {
users (): UserDto[] {
return this.$store.state.komgaUsers.users
}
},
async mounted () {
await this.$store.dispatch('getAllUsers')
},
methods: {
promptDeleteUser (user: UserDto) {
this.userToDelete = user
this.modalDeleteUser = true
}
}
})
</script>
<style scoped>
</style>

View file

@ -9,7 +9,7 @@
<div class="text-center">
<h1 class="headline mt-4">Welcome to Komga</h1>
<p class="body-1">The user interface is quite new, more features will come in future releases!</p>
<v-btn color="primary" :to="{name: 'addlibrary'}">Add library</v-btn>
<v-btn color="primary" :to="{name: 'addlibrary'}" v-if="isAdmin">Add library</v-btn>
</div>
</v-row>
</div>
@ -19,7 +19,12 @@
import Vue from 'vue'
export default Vue.extend({
name: 'Welcome'
name: 'Welcome',
computed: {
isAdmin (): boolean {
return this.$store.getters.meAdmin
}
}
})
</script>

View file

@ -1,9 +1,11 @@
import Vue from 'vue'
import Vuelidate from 'vuelidate'
import { sync } from 'vuex-router-sync'
import App from './App.vue'
import httpPlugin from './plugins/http.plugin'
import komgaFileSystem from './plugins/komga-filesystem.plugin'
import komgaLibraries from './plugins/komga-libraries.plugin'
import komgaUsers from './plugins/komga-users.plugin'
import vuetify from './plugins/vuetify'
import router from './router'
import store from './store'
@ -12,10 +14,13 @@ Vue.use(Vuelidate)
Vue.use(httpPlugin)
Vue.use(komgaFileSystem, { http: Vue.prototype.$http })
Vue.use(komgaUsers, { store: store, http: Vue.prototype.$http })
Vue.use(komgaLibraries, { store: store, http: Vue.prototype.$http })
Vue.config.productionTip = false
sync(store, router)
new Vue({
router,
store,

View file

@ -0,0 +1,60 @@
import KomgaUsersService from '@/services/komga-users.service'
import { AxiosInstance } from 'axios'
import _Vue from 'vue'
import { Module } from 'vuex/types'
let service: KomgaUsersService
const vuexModule: Module<any, any> = {
state: {
me: {} as UserDto,
users: [] as UserDto[]
},
getters: {
meAdmin: state => state.me.hasOwnProperty('roles') && state.me.roles.includes('ADMIN')
},
mutations: {
setMe (state, user: UserDto) {
state.me = user
},
setAllUsers (state, users: UserDto[]) {
state.users = users
}
},
actions: {
async getMe ({ commit }) {
commit('setMe', await service.getMe())
},
async updateMyPassword (_, newPassword: PasswordUpdateDto) {
await service.patchMePassword(newPassword)
},
async getAllUsers ({ commit }) {
commit('setAllUsers', await service.getAll())
},
async postUser ({ dispatch }, user: UserCreationDto) {
await service.postUser(user)
dispatch('getAllUsers')
},
async deleteUser ({ dispatch }, user: UserDto) {
await service.deleteUser(user)
dispatch('getAllUsers')
}
}
}
export default {
install (
Vue: typeof _Vue,
{ store, http }: { store: any, http: AxiosInstance }) {
service = new KomgaUsersService(http)
Vue.prototype.$komgaUsers = service
store.registerModule('komgaUsers', vuexModule)
}
}
declare module 'vue/types/vue' {
interface Vue {
$$komgaUsers: KomgaUsersService;
}
}

View file

@ -1,34 +1,68 @@
import Vue from 'vue'
import Router from 'vue-router'
import store from './store'
Vue.use(Router)
const lStore = store
const adminGuard = (to: any, from: any, next: any) => {
if (!lStore.getters.meAdmin) next({ name: 'home' })
else next()
}
export default new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
redirect: { name: 'welcome' },
name: 'home',
redirect: { name: 'welcome' },
component: () => import(/* webpackChunkName: "home" */ './views/Home.vue'),
children: [
{
path: '/libraries/add',
name: 'addlibrary',
beforeEnter: adminGuard,
component: () => import(/* webpackChunkName: "addlibrary" */ './components/AddLibraryDialog.vue')
},
{
path: '/welcome',
name: 'welcome',
component: () => import(/* webpackChunkName: "welcome" */ './components/Welcome.vue')
},
{
path: '/settings',
name: 'settings',
redirect: { name: 'settings-users' }
},
{
path: '/settings/users',
name: 'settings-users',
beforeEnter: adminGuard,
component: () => import(/* webpackChunkName: "settings-users" */ './components/SettingsUsers.vue'),
children: [
{
path: '/settings/users/add',
name: 'settings-users-add',
component: () => import(/* webpackChunkName: "settings-user" */ './components/AddUserDialog.vue')
}
]
},
{
path: '/account',
name: 'account',
component: () => import(/* webpackChunkName: "account" */ './components/AccountSettings.vue')
}
]
},
{
path: '*',
name: 'notfound',
component: () => import(/* webpackChunkName: "notfound" */ './views/PageNotFound.vue')
name:
'notfound',
component:
() => import(/* webpackChunkName: "notfound" */ './views/PageNotFound.vue')
}
]
})

View file

@ -10,10 +10,20 @@ export default class KomgaFilesystemService {
}
async getDirectoryListing (path: String = ''): Promise<DirectoryListingDto> {
return (await this.http.get(API_FILESYSTEM, {
params: {
path: path
try {
return (await this.http.get(API_FILESYSTEM,
{
params: {
path: path
}
}
)).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve directory listing'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
})).data
throw new Error(msg)
}
}
}

View file

@ -0,0 +1,71 @@
import { AxiosInstance } from 'axios'
const API_USERS = '/api/v1/users'
export default class KomgaUsersService {
private http: AxiosInstance;
constructor (http: AxiosInstance) {
this.http = http
}
async getMe (): Promise<UserDto> {
try {
return (await this.http.get(`${API_USERS}/me`)).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve current user'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async getAll (): Promise<UserDto[]> {
try {
return (await this.http.get(`${API_USERS}`)).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve all users'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async postUser (user: UserCreationDto): Promise<UserDto> {
try {
return (await this.http.post(API_USERS, user)).data
} catch (e) {
let msg = `An error occurred while trying to add user '${user.email}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async deleteUser (user: UserDto) {
try {
await this.http.delete(`${API_USERS}/${user.id}`)
} catch (e) {
let msg = `An error occurred while trying to delete user '${user.email}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async patchMePassword (newPassword: PasswordUpdateDto) {
try {
return (await this.http.patch(`${API_USERS}/me/password`, newPassword)).data
} catch (e) {
let msg = `An error occurred while trying to update password for current user`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View file

@ -0,0 +1,14 @@
interface UserDto {
id: number,
email: string,
roles: string[]
}
interface UserCreationDto {
email: string,
roles: string[]
}
interface PasswordUpdateDto {
password: string
}

View file

@ -5,6 +5,14 @@
hide-on-scroll
>
<v-app-bar-nav-icon @click.stop="toggleDrawer"></v-app-bar-nav-icon>
<v-tabs v-if="tabs.length > 0">
<v-tab v-for="(t, index) in tabs" :key="index"
:id="t.id"
:to="{name: t.route}"
>{{ t.name }}
</v-tab>
</v-tabs>
</v-app-bar>
<v-navigation-drawer app v-model="drawerVisible">
@ -39,37 +47,48 @@
<v-list-item-content>
<v-list-item-title>Libraries</v-list-item-title>
</v-list-item-content>
<v-btn icon :to="{name: 'addlibrary'}" exact>
<v-icon>mdi-plus</v-icon>
</v-btn>
<v-list-item-action v-if="isAdmin">
<v-btn icon :to="{name: 'addlibrary'}" exact>
<v-icon>mdi-plus</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
<v-list-item v-for="(l, index) in libraries" :key="index" dense>
<v-list-item-icon>
</v-list-item-icon>
<v-list-item-content>
<v-tooltip bottom>
<v-tooltip bottom :disabled="!isAdmin">
<template v-slot:activator="{ on }">
<v-list-item-title v-on="on">{{ l.name }}</v-list-item-title>
</template>
<span>{{ l.root }}</span>
</v-tooltip>
</v-list-item-content>
<v-list-item-action>
<v-list-item-action v-if="isAdmin">
<v-btn icon @click="promptDeleteLibrary(l)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
<!-- <v-list-item :to="{name: 'settings'}">-->
<!-- <v-list-item-action>-->
<!-- <v-icon>mdi-settings</v-icon>-->
<!-- </v-list-item-action>-->
<!-- <v-list-item-content>-->
<!-- <v-list-item-title>Settings</v-list-item-title>-->
<!-- </v-list-item-content>-->
<!-- </v-list-item>-->
<v-list-item :to="{name: 'settings'}" v-if="isAdmin">
<v-list-item-action>
<v-icon>mdi-settings</v-icon>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Server settings</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item :to="{name: 'account'}">
<v-list-item-action>
<v-icon>mdi-account</v-icon>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Account settings</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- <v-list-item @click="logout">-->
<!-- <v-list-item-icon>-->
@ -126,10 +145,25 @@ export default Vue.extend({
computed: {
libraries (): LibraryDto[] {
return this.$store.state.komgaLibraries.libraries
},
isAdmin (): boolean {
return this.$store.getters.meAdmin
},
tabs () {
if (this.$store.state.route.name) {
if (this.$store.state.route.name.startsWith('settings')) {
return [
{ id: 'tab-users', route: 'settings-users', name: 'Users' }
]
}
return []
}
return []
}
},
async mounted () {
try {
await this.$store.dispatch('getMe')
await this.$store.dispatch('getLibraries')
} catch (e) {
this.showSnack(e.message)