Reworked user and session store, added Admin function for user.

* Sync Login with backend
* Split Main into MainUserSettins and Settings
* Added AdminSetting to change other users, added UserSelector Component
for selecting users (can be reused for other stuff ;-) ).
* Split hasPermission into helper file for code reuse
This commit is contained in:
Ferdinand Thiessen 2020-11-06 01:17:04 +01:00
parent 5c11e02b2c
commit 8689e84d47
12 changed files with 435 additions and 300 deletions

View File

@ -5,16 +5,8 @@ import { Store } from 'vuex';
export default boot<Store<StateInterface>>(({ router, store }) => {
router.beforeEach((to, from, next) => {
const user = store.state.user.currentUser;
const session = store.state.session.currentSession;
let permissions: string[] = [];
if (user) {
user.roles.forEach(role => {
permissions = permissions.concat(role.permissions);
});
}
if (to.name != 'login') {
if (!session || session.expires <= new Date()) {
store.dispatch('session/logout').catch(error => {
@ -32,7 +24,7 @@ export default boot<Store<StateInterface>>(({ router, store }) => {
return (<{ permissions: FG.Permission[] }>(
record.meta
)).permissions.every((permission: string) => {
return permissions.includes(permission);
return store.state.user.currentPermissions.includes(permission);
});
}
}

View File

@ -23,6 +23,7 @@
import { computed, defineComponent } from '@vue/composition-api';
import { Store } from 'vuex';
import { StateInterface } from 'src/store';
import { hasPermissions } from 'src/components/permission';
export default defineComponent({
name: 'EssentialLink',
@ -65,17 +66,9 @@ export default defineComponent({
return props.title;
});
const hasPermissions = computed(() => {
let permissions = props.permissions;
if (permissions) {
return (<string[]>permissions).every(permission => {
return (<{ 'user/permissions': string[] }>(
(<Store<StateInterface>>root.$store).getters
))['user/permissions'].includes(permission);
});
}
return true;
});
const isGranted = computed(() =>
hasPermissions(props.permissions || [], root.$store)
);
return { realTitle: title, hasPermissions };
}

View File

@ -1,11 +1,12 @@
<template>
<q-btn flat dense :icon="icon" :to="{ name: link }" v-if="hasPermissions" />
<q-btn flat dense :icon="icon" :to="{ name: link }" v-if="isGranted" />
</template>
<script lang="ts">
import { computed, defineComponent } from '@vue/composition-api';
import { Store } from 'vuex';
import { StateInterface } from 'src/store';
import { hasPermissions } from 'src/components/permission';
export default defineComponent({
name: 'ShortCutLink',
@ -23,18 +24,10 @@ export default defineComponent({
}
},
setup(props, { root }) {
const hasPermissions = computed(() => {
let permissions = props.permissions;
if (permissions) {
return (<string[]>permissions).every(permission => {
return (<{ 'user/permissions': string[] }>(
(<Store<StateInterface>>root.$store).getters
))['user/permissions'].includes(permission);
});
}
return true;
});
return { hasPermissions };
const isGranted = computed(() =>
hasPermissions(props.permissions || [], root.$store)
);
return { isGranted };
}
});
</script>

View File

@ -0,0 +1,14 @@
import { Store } from 'vuex';
import { StateInterface } from 'src/store';
export function hasPermission(
permission: string,
store: Store<StateInterface>
) {
return store.state.user.currentPermissions.includes(permission);
}
export function hasPermissions(needed: string[], store: Store<StateInterface>) {
const permissions = store.state.user.currentPermissions;
return needed.every(value => permissions.includes(value));
}

View File

@ -0,0 +1,41 @@
<template>
<q-select
filled
label="Benutzer"
@input="updated"
v-model="user"
:options="users"
option-label="display_name"
option-value="userid"
map-options
/>
</template>
<script lang="ts">
import { computed, defineComponent, ref } from '@vue/composition-api';
import { Store } from 'vuex';
import { StateInterface } from 'src/store';
interface Props {
user: FG.User;
}
export default defineComponent({
name: 'UserSelector',
props: ['user'],
setup(props: Props, { root, emit }) {
const store = <Store<StateInterface>>root.$store;
const users = computed(() => store.state.user.users);
const user = ref(props.user);
const updated = (value: FG.User) => {
emit('update:user', value);
};
return {
user,
updated,
users
};
}
});
</script>

View File

@ -1,191 +0,0 @@
<template>
<q-card class="col-12">
<q-linear-progress indeterminate rounded color="primary" v-if="loading" />
<q-form @submit="save" @reset="reset">
<q-card-section class="fit row justify-start content-center items-center">
<q-input
class="col-xs-12 col-sm-6 q-pa-sm"
label="Vorname"
:rules="[notEmpty]"
v-model="firstname"
filled
/>
<q-input
class="col-xs-12 col-sm-6 q-pa-sm"
label="Nachname"
:rules="[notEmpty]"
v-model="lastname"
filled
/>
<q-input
class="col-xs-12 col-sm-6 q-pa-sm"
label="Benutzername"
readonly
:value="user.userid"
filled
/>
<q-input
class="col-xs-12 col-sm-6 q-pa-sm"
label="E-Mail"
:rules="[isEmail, notEmpty]"
v-model="mail"
filled
/>
<q-input
class="col-xs-12 col-sm-6 q-pa-sm"
label="Display Name"
:rules="[notEmpty]"
v-model="display_name"
filled
/>
<q-select
class="col-xs-12 col-sm-6 q-pa-sm"
label="Rollen"
readonly
v-model="user.roles"
:options="user.roles"
filled
>
<template v-slot:selected-item="scope">
<q-chip v-for="(item, index) in scope.opt" :key="'item' + index">
{{ item.name }}
</q-chip>
</template>
</q-select>
</q-card-section>
<q-separator />
<q-card-section class="fit row justify-start content-center items-center">
<q-input
class="col-xs-12 col-sm-6 col-md-4 q-pa-sm"
label="Password"
type="password"
hint="Password muss immer eingetragen werden"
:rules="[notEmpty]"
v-model="password"
filled
/>
<q-input
class="col-xs-12 col-sm-6 col-md-4 q-pa-sm"
label="Neues Password"
type="password"
v-model="new_password"
filled
/>
<q-input
class="col-xs-12 col-sm-6 col-md-4 q-pa-sm"
label="Wiederhole neues Password"
type="password"
:disable="new_password.length == 0"
:rules="[samePassword]"
v-model="new_password2"
filled
/>
</q-card-section>
<q-card-actions align="right">
<q-btn label="test" @click="$store.dispatch('user/getUser')" />
<q-btn label="Reset" type="reset" />
<q-btn color="primary" type="submit" label="Speichern" />
</q-card-actions>
</q-form>
</q-card>
</template>
<script lang="ts">
import { computed, defineComponent, ref } from '@vue/composition-api';
import { Store } from 'vuex';
import { StateInterface } from 'src/store';
export default defineComponent({
name: 'Main',
setup(_, { root }) {
const store = <Store<StateInterface>>root.$store;
const user = computed(() => <FG.User>store.state.user.currentUser);
const firstname = ref(user.value?.firstname);
const lastname = ref(user.value?.lastname);
const mail = ref(user.value?.mail);
const display_name = ref(user.value?.display_name);
const password = ref('');
const new_password = ref('');
const new_password2 = ref('');
function save() {
let change_values: { [index: string]: string } = {
firstname: firstname.value,
lastname: lastname.value,
mail: mail.value,
display_name: display_name.value
};
Object.keys(change_values).forEach(key => {
if (
change_values[key] === (<{ [index: string]: any }>user.value)[key]
) {
delete change_values[key];
}
});
change_values = Object.assign(change_values, {
password: password.value
});
if (new_password.value != '') {
change_values = Object.assign(change_values, {
new_password: new_password.value
});
}
store.dispatch('user/updateUser', change_values).catch(error => {
console.warn(error);
});
}
function reset() {
firstname.value = user.value.firstname;
lastname.value = user.value.lastname;
mail.value = user.value.mail;
display_name.value = user.value.display_name;
password.value = '';
new_password.value = '';
new_password2.value = '';
}
function samePassword(val: string) {
return val == new_password.value || 'Passwörter sind nicht identisch!';
}
function notEmpty(val: string) {
return !!val || 'Feld darf nicht leer sein!';
}
function isEmail(val: string | null) {
return (
!val ||
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w\w+)+$/.test(val) ||
'E-Mail ist nicht valide.'
);
}
const loading = computed(() => {
return (
store.state.user.getUserLoading || store.state.user.updateUserLoading
);
});
return {
user,
firstname,
lastname,
mail,
display_name,
password,
new_password,
new_password2,
samePassword,
isEmail,
notEmpty,
save,
reset,
loading
};
}
});
</script>

View File

@ -0,0 +1,194 @@
<template>
<q-form @submit="save" @reset="reset">
<q-linear-progress indeterminate rounded color="primary" v-if="loading" />
<q-card-section class="fit row justify-start content-center items-center">
<q-input
class="col-xs-12 col-sm-6 q-pa-sm"
label="Vorname"
:rules="[notEmpty]"
v-model="props.user.firstname"
filled
/>
<q-input
class="col-xs-12 col-sm-6 q-pa-sm"
label="Nachname"
:rules="[notEmpty]"
v-model="props.user.lastname"
filled
/>
<q-input
class="col-xs-12 col-sm-6 q-pa-sm"
label="Benutzername"
readonly
:value="props.user.userid"
filled
/>
<q-input
class="col-xs-12 col-sm-6 q-pa-sm"
label="E-Mail"
:rules="[isEmail, notEmpty]"
v-model="props.user.mail"
filled
/>
<q-input
class="col-xs-12 col-sm-6 q-pa-sm"
label="Display Name"
:rules="[notEmpty]"
v-model="props.user.display_name"
filled
/>
<q-select
class="col-xs-12 col-sm-6 q-pa-sm"
label="Rollen"
filled
multiple
use-chips
v-model="props.user.roles"
:readonly="() => canSetRoles()"
:options="allRoles"
option-label="name"
option-value="name"
/>
</q-card-section>
<q-separator />
<q-card-section class="fit row justify-start content-center items-center">
<q-input
v-if="isCurrentUser"
class="col-xs-12 col-sm-6 col-md-4 q-pa-sm"
label="Password"
type="password"
hint="Password muss immer eingetragen werden"
:rules="[notEmpty]"
v-model="password"
filled
/>
<q-input
class="col-xs-12 col-sm-6 col-md-4 q-pa-sm"
label="Neues Password"
type="password"
v-model="new_password"
filled
/>
<q-input
class="col-xs-12 col-sm-6 col-md-4 q-pa-sm"
label="Wiederhole neues Password"
type="password"
:disable="new_password.length == 0"
:rules="[samePassword]"
v-model="new_password2"
filled
/>
</q-card-section>
<q-card-actions align="right">
<q-btn label="Reset" type="reset" />
<q-btn color="primary" type="submit" label="Speichern" />
</q-card-actions>
</q-form>
</template>
<script lang="ts">
import {
computed,
defineComponent,
ref,
onBeforeMount
} from '@vue/composition-api';
import { Store } from 'vuex';
import { StateInterface } from 'src/store';
import { hasPermission } from 'src/components/permission';
interface Props {
user?: FG.User;
}
export default defineComponent({
name: 'MainUserSettings',
props: ['user'],
setup(props: Props, { root }) {
const store = <Store<StateInterface>>root.$store;
onBeforeMount(() => {
store.dispatch('user/getRoles', false).catch(error => {
console.warn(error);
});
});
const isCurrentUser = computed(
() => props.user?.userid === store.state.user.currentUser?.userid
);
const canSetRoles = computed(() => hasPermission('users_set_roles', store));
const oldUser = computed(() => {
if (isCurrentUser.value) return <FG.User>store.state.user.currentUser;
else
return store.state.user.users.filter(user => {
user.userid === props.user?.userid;
})[0];
});
const allRoles = computed(() =>
store.state.user.roles.map(role => role.name)
);
const password = ref('');
const new_password = ref('');
const new_password2 = ref('');
function save() {
let changed = <FG.User>props.user;
changed = Object.assign(changed, {
password: password.value
});
if (new_password.value != '') {
changed = Object.assign(changed, {
new_password: new_password.value
});
}
store.dispatch('user/updateUser', changed).catch(error => {
console.warn(error);
});
}
function reset() {
props.user = oldUser.value;
password.value = '';
new_password.value = '';
new_password2.value = '';
}
function samePassword(val: string) {
return val == new_password.value || 'Passwörter sind nicht identisch!';
}
function notEmpty(val: string) {
return !!val || 'Feld darf nicht leer sein!';
}
function isEmail(val: string | null) {
return (
!val ||
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w\w+)+$/.test(val) ||
'E-Mail ist nicht valide.'
);
}
const loading = computed(() => store.state.user.loading > 0);
return {
props,
allRoles,
canSetRoles,
password,
new_password,
new_password2,
samePassword,
isCurrentUser,
isEmail,
notEmpty,
save,
reset,
loading
};
}
});
</script>

View File

@ -0,0 +1,55 @@
<template>
<div>
<q-page
padding
class="fit row justify-center content-center items-center q-gutter-sm"
>
<q-card class="col-12">
<q-card-section
class="fit row justify-start content-center items-center"
>
<div class="col-xs-12 col-sm-6 text-center text-h6">
Benutzereinstellungen
</div>
<div class="col-xs-12 col-sm-6 q-pa-sm">
<UserSelector :user="user" @update:user="userUpdated" />
</div>
</q-card-section>
<MainUserSettings :user="user" />
</q-card>
</q-page>
</div>
</template>
<script lang="ts">
import { defineComponent, onBeforeMount, ref } from '@vue/composition-api';
import UserSelector from '../components/UserSelector.vue';
import MainUserSettings from '../components/settings/MainUserSettings.vue';
import { Store } from 'vuex';
import { StateInterface } from 'src/store';
export default defineComponent({
name: 'AdminSettings',
components: { UserSelector, MainUserSettings },
setup(_, { root }) {
const store = <Store<StateInterface>>root.$store;
onBeforeMount(() => {
store.dispatch('user/getUsers').catch(error => console.warn(error));
});
const user = ref(<FG.User>store.state.user.currentUser);
// can be dropped with VUE3
const userUpdated = (value: FG.User) => {
user.value = value;
console.log(value);
};
return {
user,
userUpdated
};
}
});
</script>

View File

@ -3,42 +3,42 @@
<q-page
padding
class="fit row justify-center content-center items-center q-gutter-sm"
>
<div
class="fit row justify-center content-center items-center q-gutter-sm"
>
<circular-progress v-if="sessionsLoading" />
<div class="col-12 text-left text-h6">
Allgemeine Einstellungen:
</div>
<Main />
<div class="col-12 text-left text-h6">
Aktive Sessions:
</div>
<q-card class="col-12">
<q-card-section
class="fit row justify-start content-center items-center"
>
<div class="col-12 text-center text-h6">Benutzereinstellungen</div>
</q-card-section>
<MainUserSettings :user="currentUser" />
</q-card>
<div class="col-12 text-left text-h6">Aktive Sessions:</div>
<sessions
v-for="(session, index) in sessions"
:key="'session' + index"
:session="session"
/>
</div>
<div class="row">
<q-btn label="show sessions" @click="showRootGetters" />
</div>
</q-page>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, onBeforeMount } from '@vue/composition-api';
import {
computed,
defineComponent,
onBeforeMount,
ref
} from '@vue/composition-api';
import CircularProgress from 'components/loading/CircularProgress.vue';
import Sessions from '../components/settings/Sessions.vue';
import Main from '../components/settings/Main.vue';
import MainUserSettings from '../components/settings/MainUserSettings.vue';
import { Store } from 'vuex';
import { StateInterface } from 'src/store';
export default defineComponent({
// name: 'PageName'
components: { CircularProgress, Sessions, Main },
components: { CircularProgress, Sessions, MainUserSettings },
setup(_, { root }) {
const store = <Store<StateInterface>>root.$store;
@ -47,21 +47,13 @@ export default defineComponent({
console.warn(error);
});
});
const currentUser = ref(<FG.User>store.state.user.currentUser);
const sessions = computed(() => store.state.session.sessions);
function showRootGetters() {
console.log(sessions.value);
}
const sessionsLoading = computed(
() =>
store.state.session.loading ||
store.state.user.getUserLoading ||
store.state.user.updateUserLoading
);
const sessionsLoading = computed(() => store.state.session.loading);
return {
showRootGetters,
currentUser,
sessionsLoading,
sessions
};

View File

@ -25,6 +25,15 @@ const mainRoutes: FG_Plugin.PluginRouteConfig[] = [
shortcut: true,
meta: { permissions: ['user'] },
component: () => import('../pages/Settings.vue')
},
{
title: 'Admin',
icon: 'mdi-cog',
path: 'admin',
name: 'admin-settings',
shortcut: false,
meta: { permissions: ['users_edit_other'] },
component: () => import('../pages/AdminSettings.vue')
}
]
}

View File

@ -2,7 +2,7 @@ import { Module, MutationTree, ActionTree, GetterTree } from 'vuex';
import { LoginData, LoginResponse } from 'src/plugins/user/models';
import { StateInterface } from 'src/store';
import { axios } from 'src/boot/axios';
import { AxiosResponse } from 'axios';
import { AxiosError, AxiosResponse } from 'axios';
import { Router } from 'src/router';
import { LocalStorage, Loading } from 'quasar';
@ -12,10 +12,15 @@ export interface SessionInterface {
loading: boolean;
}
function loadFromLocal() {
const session = LocalStorage.getItem<FG.Session>('currentSession');
if (session) session.expires = new Date(session.expires);
return session;
}
const state: SessionInterface = {
sessions: [],
currentSession:
LocalStorage.getItem<FG.Session>('currentSession') || undefined,
currentSession: loadFromLocal() || undefined,
loading: false
};
@ -47,12 +52,13 @@ const actions: ActionTree<SessionInterface, StateInterface> = {
response.data.session.expires = new Date(response.data.session.expires);
commit('setCurrentSession', response.data.session);
commit('user/setCurrentUser', response.data.user, { root: true });
void Router.push({ name: 'user-main' });
})
.catch(error => {
console.exception(error);
commit('user/setCurrentPermissions', response.data.permissions, {
root: true
});
})
.catch(error => console.warn(error))
.finally(() => {
void Router.push({ name: 'user-main' });
Loading.hide();
});
},
@ -70,7 +76,7 @@ const actions: ActionTree<SessionInterface, StateInterface> = {
/**
* Delete a given session
*/
deleteSession({ commit, rootState }, token: string | null) {
deleteSession({ commit, dispatch, rootState }, token: string | null) {
if (token === null) return;
commit('setLoading', true);
@ -78,17 +84,23 @@ const actions: ActionTree<SessionInterface, StateInterface> = {
.delete(`/auth/${token}`)
.then(() => {
if (token === rootState.session.currentSession?.token) {
commit('clearCurrentSession');
commit('user/clearCurrentUser', null, { root: true });
void Router.push({ name: 'login' });
void dispatch('clearup');
} else {
commit('getSessions');
}
})
.catch((error: AxiosError) => {
if (!error.response || error.response.status != 401) throw error;
})
.finally(() => {
commit('setLoading', false);
});
},
clearup({ commit }) {
commit('clearCurrentSession');
commit('user/clearCurrentUser', null, { root: true });
void Router.push({ name: 'login' });
},
/**
* Get all sessions from current User
*/

View File

@ -3,19 +3,25 @@ import { StateInterface } from 'src/store';
import { axios } from 'boot/axios';
import { AxiosResponse } from 'axios';
import { SessionStorage } from 'quasar';
import { CurrentUserResponse } from 'src/plugins/user/models';
export interface UserStateInterface {
updateUserLoading: boolean;
getUserLoading: boolean;
currentUser?: FG.User;
currentPermissions: FG.Permission[];
users: FG.User[];
roles: FG.Role[];
permissions: FG.Permission[];
loading: number;
}
const state: UserStateInterface = {
users: [],
roles: [],
permissions: [],
currentUser: SessionStorage.getItem<FG.User>('currentUser') || undefined,
updateUserLoading: false,
getUserLoading: false
currentPermissions:
SessionStorage.getItem<FG.Permission[]>('currentPermissions') || [],
loading: 0
};
const mutations: MutationTree<UserStateInterface> = {
@ -23,35 +29,46 @@ const mutations: MutationTree<UserStateInterface> = {
SessionStorage.set('currentUser', data);
state.currentUser = data;
},
setCurrentPermissions(state, data: FG.Permission[]) {
SessionStorage.set('currentPermissions', data);
state.currentPermissions = data;
},
clearCurrentUser(state) {
SessionStorage.remove('currentUser');
SessionStorage.remove('currentPermissions');
state.currentUser = undefined;
state.currentPermissions = [];
},
setUsers(state, data: FG.User[]) {
state.users = data;
},
setLoading(
state,
data: { key: 'updateUserLoading' | 'getUserLoading'; data: boolean }
) {
state[data.key] = data.data;
setRoles(state, data: FG.Role[]) {
state.roles = data;
},
setPermissions(state, data: FG.Permission[]) {
state.permissions = data;
},
setLoading(state, data = true) {
if (data) state.loading += 1;
else state.loading -= 1;
}
};
const actions: ActionTree<UserStateInterface, StateInterface> = {
getCurrentUser({ commit, rootState }) {
if (rootState.session.currentSession) {
commit('setLoading', { key: 'getUserLoading', data: true });
commit('setLoading');
axios
.get(`/users/${rootState.session.currentSession.userid}`)
.then((response: AxiosResponse<FG.User>) => {
.then((response: AxiosResponse<CurrentUserResponse>) => {
commit('setCurrentUser', response.data);
commit('setCurrentPermissions', response.data.permissions);
})
.catch(err => {
console.warn(err);
})
.finally(() => {
commit('setLoading', { key: 'getUserLoading', data: false });
commit('setLoading', false);
});
} else {
console.debug('User not logged in, can not get current_user.');
@ -60,7 +77,7 @@ const actions: ActionTree<UserStateInterface, StateInterface> = {
getUsers({ commit }) {
axios
.get(`/users`)
.get('/users')
.then((response: AxiosResponse<FG.User[]>) => {
commit('setUsers', response.data);
})
@ -69,20 +86,43 @@ const actions: ActionTree<UserStateInterface, StateInterface> = {
});
},
updateUser({ commit, state, dispatch }, data) {
commit('setLoading', { key: 'updateUserLoading', data: true });
if (!state.currentUser) throw 'Not logged in';
updateUser({ commit, state, dispatch }, data: FG.User) {
commit('setLoading');
axios
.put(`/users/${state.currentUser.userid}`, data)
.put(`/users/${data.userid}`, data)
.then(() => {
if (state.currentUser && state.currentUser.userid === data.userid)
void dispatch('getCurrentUser');
else void dispatch('getUsers');
})
.catch(error => {
console.log(error);
})
.finally(() => {
commit('setLoading', { key: 'updateUserLoading', data: false });
commit('setLoading', false);
});
},
getRoles({ commit, state }, force = true) {
if (!force && state.roles.length > 0) return;
commit('setLoading');
axios
.get('/roles')
.then((response: AxiosResponse<FG.Role[]>) => {
commit('setRoles', response.data);
})
.finally(() => commit('setLoading', false));
},
getPermissions({ commit, state }, force = true) {
if (!force && state.permissions.length > 0) return;
commit('setLoading');
axios
.get('/roles')
.then((response: AxiosResponse<FG.Permission[]>) => {
commit('setPermissions', response.data);
})
.finally(() => commit('setLoading', false));
}
};
@ -93,20 +133,11 @@ const getters: GetterTree<UserStateInterface, StateInterface> = {
users({ users }) {
return users;
},
loading({ updateUserLoading, getUserLoading }) {
return updateUserLoading || getUserLoading;
loading({ loading }) {
return loading > 0;
},
displayName({ currentUser }) {
return currentUser?.display_name;
},
permissions({ currentUser }) {
let permissions: string[] = [];
if (currentUser) {
currentUser.roles.forEach(role => {
permissions = permissions.concat(role.permissions);
});
}
return permissions;
}
};