Added user management functionality to the configuration page:

- Introduced `Users` tab in `Config.vue` with a dynamic display controlled by user rights.
- Added `EditUser.vue` and `Users.vue` components for managing user details and permissions via modal dialogs.
- Implemented new `users` store handling user records, pagination, and updates.
- Updated `TableView.vue` to support badge rendering and dynamic configurations.
- Adjusted routing guards for consistent user data fetching and permissions.
- Various UI refinements and component reorganization for better maintainability.
This commit is contained in:
Jan 2025-11-18 17:30:56 +01:00
parent 83e007088c
commit c57c2ff19d
11 changed files with 504 additions and 47 deletions

View file

@ -1,20 +1,23 @@
<template>
<div class="app-group-item">
<div class="group-item">
<tooltip :text="groupObj.group_description">
<checkbox :checked="selected" @checkbox-changed="checkboxChanged"></checkbox>
<div>
<div class="app-group-item-name">{{ groupObj.group_name }}</div>
<div class="app-group-item-descr">{{ groupObj.group_description }}</div>
<div class="group-item-name">{{ groupObj.group_name }}</div>
<!-- <div class="app-group-item-descr">{{ groupObj.group_description }}</div>-->
</div>
</tooltip>
</div>
</template>
<script>
import Checkbox from "@/components/UI/Checkbox.vue";
import Tooltip from "@/components/UI/Tooltip.vue";
export default {
name: "AppGroupItem",
components: {Checkbox},
name: "GroupItem",
components: {Tooltip, Checkbox},
props: {
groupObj: {
type: Object,
@ -35,17 +38,17 @@ export default {
<style scoped>
.app-group-item {
.group-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
padding: 1.6rem;
}
.app-group-item-name {
font-weight: 500;
.group-item-name {
font-weight: 400;
font-size: 1.4rem;
}

View file

@ -1,7 +1,7 @@
<template>
<div class="table-container">
<div class="table-search-bar-container">
<div v-if="searchbar" class="table-search-bar-container">
<search-bar class="search-bar" v-model="filter" @input-changed="reload"></search-bar>
</div>
@ -37,8 +37,10 @@
<td v-for="column in columns" :key="column.key" class="table-cell" :class="getAlignment(column.align)">
<flag v-if="column.showFlag" :tooltip-text="getCellValue(item, column)" :iso="getCellValue(item, column)"></flag>
<div class="badge-container" v-else-if="column.badgeResolver != null && typeof column.badgeResolver === 'function'">
<basic-badge v-for="badge in getCellValue(item, column)" :variant="badge.variant">{{ badge.text }}</basic-badge>
</div>
<span v-else-if="column.iconResolver == null">{{ getCellValue(item, column) }}</span>
<span v-else-if="column.badgeResolver">{{ getCellValue(item, column) }}</span>
<component v-else
:is="getCellValue(item, column)"
weight="regular"
@ -69,10 +71,12 @@ import SearchBar from "@/components/UI/SearchBar.vue";
import Box from "@/components/UI/Box.vue";
import Pagination from "@/components/UI/Pagination.vue";
import Flag from "@/components/UI/Flag.vue";
import basicBadge from "@/components/UI/BasicBadge.vue";
import BasicBadge from "@/components/UI/BasicBadge.vue";
export default {
name: "TableView",
components: {Flag, Pagination, Box, SearchBar, BasicButton, Checkbox, Spinner},
components: {BasicBadge, Flag, Pagination, Box, SearchBar, BasicButton, Checkbox, Spinner},
emits: ['row-click'],
props: {
dataSource: {
@ -84,6 +88,10 @@ export default {
default: false,
},
searchbar: {
type: Boolean,
default: true
},
columns: {
type: Array,
required: true,
@ -111,7 +119,11 @@ export default {
mounted() {
this.reload();
},
computed: {},
computed: {
basicBadge() {
return basicBadge
}
},
methods: {
async updatePage(page) {
this.reload(this.filter, page);
@ -145,6 +157,10 @@ export default {
return column.iconResolver(rawValue, item);
}
if (column.badgeResolver && typeof column.badgeResolver === 'function') {
return column.badgeResolver(rawValue, item);
}
if (column.iconResolver) {
return 'PhImageBroken';
}
@ -178,6 +194,11 @@ export default {
opacity: 0;
}
.badge-container {
display: flex;
gap: 0.8rem;
}
.table-icon {
transition: all 0.1s ease-in-out;
color: #6B869C;

View file

@ -5,13 +5,13 @@
<div class="add-app">
<div>App name</div>
<div>
<div class="text-container"><input class="input-field" v-model="appName" @input="checkChange"/></div>
<div class="text-container"><input class="input-field" v-model="appName" /></div>
</div>
<div class="add-app-group-header">App groups</div>
<div>
<app-group-item v-for="group in groups" :group-obj="group" :key="group.group_name"
<div class="groups-container">
<group-item v-for="group in groups" :group-obj="group" :key="group.group_name"
:selected="isSelected(group.group_name)"
@checkbox-changed="updateSelected"></app-group-item>
@checkbox-changed="updateSelected"></group-item>
</div>
<div></div>
</div>
@ -49,14 +49,14 @@ import {mapStores} from "pinia";
import {useGroupStore} from "@/store/group.js";
import Checkbox from "@/components/UI/Checkbox.vue";
import BasicButton from "@/components/UI/BasicButton.vue";
import AppGroupItem from "@/components/UI/AppGroupItem.vue";
import GroupItem from "@/components/UI/GroupItem.vue";
import {useAppsStore} from "@/store/apps.js";
import checkbox from "@/components/UI/Checkbox.vue";
import Box from "@/components/UI/Box.vue";
export default {
name: "AddApp",
components: {Box, AppGroupItem, BasicButton, Checkbox, InputField},
components: {Box, GroupItem, BasicButton, Checkbox, InputField},
computed: {
checkbox() {
return checkbox
@ -117,10 +117,19 @@ export default {
<style scoped>
.groups-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 1.8rem;
}
.stage-container {
position: relative;
min-height: 20rem;
margin: 1.6rem;
min-width: 70rem;
}
/* Transition animations */
@ -144,8 +153,8 @@ export default {
.add-app {
display: grid;
grid-template-columns: 1fr auto;
grid-gap: 10px;
grid-template-columns: auto 1fr;
grid-gap: 1.6rem;
font-weight: 500;
font-size: 1.4rem;
align-items: center;
@ -153,7 +162,7 @@ export default {
.add-app-group-header {
align-self: start;
padding-top: 2.4rem;
}
.text-container {

View file

@ -24,7 +24,7 @@ import AppListItem from "@/components/UI/AppListItem.vue";
import {mapStores} from "pinia";
import {useAppsStore} from "@/store/apps.js";
import Modal from "@/components/UI/Modal.vue";
import AddApp from "@/components/UI/AddApp.vue";
import AddApp from "@/components/layout/config/AddApp.vue";
export default {
name: "Apps",

View file

@ -0,0 +1,193 @@
<template>
<div class="edit-user-container">
<div class="edit-user">
<div>Workday ID</div>
<div>
<div class="text-container" :class="{disabled: !isNewUser}"><input :disabled="!isNewUser" class="input-field"
v-model="user.workday_id"/></div>
</div>
<div>Firstname</div>
<div>
<div class="text-container"><input class="input-field" v-model="user.firstname"/></div>
</div>
<div>Lastname</div>
<div>
<div class="text-container"><input class="input-field" v-model="user.lastname"/></div>
</div>
<div>E-Mail</div>
<div>
<div class="text-container"><input class="input-field" v-model="user.mail"/></div>
</div>
<div class="group-header">Groups</div>
<div class="groups-container">
<group-item v-for="group in groups" :group-obj="group" :key="group.group_name"
:selected="isSelected(group.group_name)"
@checkbox-changed="updateSelected"></group-item>
</div>
<div></div>
</div>
<div class="add-app-footer">
<basic-button :show-icon="false" @click="closeModal(true)" :disabled="disableButton"> {{
applyText
}}
</basic-button>
<basic-button :show-icon="false" @click="closeModal(false)" variant="secondary">Cancel</basic-button>
</div>
</div>
</template>
<script>
import BasicButton from "@/components/UI/BasicButton.vue";
import GroupItem from "@/components/UI/GroupItem.vue";
import {mapStores} from "pinia";
import {useGroupStore} from "@/store/group.js";
export default {
name: "EditUser",
components: {GroupItem, BasicButton},
props: {
user: {
type: Object,
required: true
},
isNewUser: {
type: Boolean,
default: false,
}
},
data() {
return {
groups: null,
}
},
methods: {
closeModal(save) {
this.$emit("close", save);
},
isSelected(groupName) {
if (groupName === null || this.user?.groups == null || Object.keys(this.groups).length === 0)
return false;
return this.user.groups.includes(groupName);
},
updateSelected(checked, groupName) {
const idx = this.user.groups?.indexOf(groupName);
console.log("update selected", idx, checked, groupName, this.user.groups);
if (checked) {
if ((idx ?? null) !== null && idx === -1)
this.user.groups.push(groupName);
} else {
if ((idx ?? null) !== null && idx !== -1)
this.user.groups.splice(idx);
}
},
},
computed: {
...mapStores(useGroupStore),
applyText() {
return this.isNewUser ? "Create user" : "Update user";
},
mailValid() {
return String(this.user.mail)
.toLowerCase()
.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
);
},
disableButton() {
return !this.mailValid ||
!(this.user.groups.length !== 0
&& this.user.firstname?.length !== 0
&& this.user.lastname?.length !== 0
&& this.user.mail?.length !== 0
&& this.user.workday_id?.length !== 0);
}
},
async created() {
await this.groupStore.loadGroups()
this.groups = this.groupStore.groups
this.groups?.forEach(group => {
group.selected = false;
})
},
}
</script>
<style scoped>
.text-container.disabled {
background-color: #f3f4f6;
cursor: not-allowed;
border-color: #f3f4f6;
}
.text-container.disabled input {
cursor: not-allowed;
}
.groups-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 1.8rem;
}
.edit-user-container {
min-width: 100rem;
}
.edit-user {
padding: 1.6rem;
display: grid;
grid-template-columns: auto 1fr;
grid-gap: 1.6rem;
font-weight: 500;
font-size: 1.4rem;
align-items: center;
}
.group-header {
align-self: start;
}
.text-container {
display: flex;
align-items: center;
background: white;
border-radius: 0.4rem;
padding: 0.6rem 1.2rem;
/* box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);*/
border: 0.2rem solid #E3EDFF;
transition: all 0.1s ease;
flex: 1 0 auto;
}
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
width: 100%;
min-width: 5rem;
}
.add-app-footer {
display: flex;
justify-content: flex-end;
gap: 1.6rem;
}
</style>

View file

@ -0,0 +1,148 @@
<template>
<div>
<div class="user-list">
<table-view ref="tableViewRef" :searchbar="false" :columns="columns" :data-source="fetch" @row-click="selectUser"
:mouse-over="true"></table-view>
</div>
<modal :state="showModal">
<edit-user @close="closeModal" v-model:user="selectedUser" :is-new-user="isNewUser"></edit-user>
</modal>
<basic-button icon="Plus" @click="createUser">New User</basic-button>
</div>
</template>
<script>
import {mapStores} from "pinia";
import {useUsersStore} from "@/store/users.js";
import TableView from "@/components/UI/TableView.vue";
import BasicButton from "@/components/UI/BasicButton.vue";
import Modal from "@/components/UI/Modal.vue";
import AddApp from "@/components/layout/config/AddApp.vue";
import EditUser from "@/components/layout/config/EditUser.vue";
import logger from "@/logger.js";
export default {
name: "Users",
props: {
isSelected: {
type: Boolean,
default: false
}
},
components: {EditUser, AddApp, Modal, BasicButton, TableView},
computed: {
...mapStores(useUsersStore)
},
watch: {
async isSelected(newVal) {
if (newVal === true) {
await this.usersStore.load();
}
}
},
created() {
this.usersStore.load();
},
methods: {
async fetch(query) {
await this.usersStore.setQuery(query);
this.pagination = this.usersStore.pagination;
return this.usersStore.users;
},
selectUser(user) {
this.isNewUser = false;
const groups = [...user.groups];
this.selectedUser = {
firstname: user.firstname,
lastname: user.lastname,
mail: user.mail,
workday_id: user.workday_id,
groups: groups
}
this.showModal = true;
},
createUser() {
this.isNewUser = true;
this.selectedUser = {
firstname: null,
lastname: null,
mail: null,
workday_id: null,
groups: []
}
this.showModal = true;
},
async closeModal(save) {
this.showModal = false;
if (save) {
await this.usersStore.updateUser(this.selectedUser);
await this.usersStore.load();
this.selectedUser = null;
this.$refs.tableViewRef.reload('', 1);
}
},
},
data() {
return {
isNewUser: false,
showModal: false,
selectedUser: null,
users: null,
columns: [
{
key: 'workday_id',
label: 'Workday ID',
},
{
key: 'firstname',
label: 'First name',
},
{
key: 'lastname',
label: 'Last name',
},
{
key: 'mail',
label: 'E-Mail',
},
{
key: 'groups',
label: 'User groups',
badgeResolver: (value) => {
const formattedValues = []
value.slice(0,5).forEach(v => formattedValues.push({text: v, variant: "secondary"}));
if(value.length > 5)
formattedValues.push({text: "...", variant: "secondary"});
return formattedValues;
}
},
],
pagination: {
page: 1,
pageCount: 1,
totalCount: 0
},
pageSize: 20
}
}
}
</script>
<style scoped>
.user-list {
margin-bottom: 2.4rem;
}
</style>

View file

@ -28,6 +28,7 @@ import ErrorLog from "@/pages/ErrorLog.vue";
import {mapStores} from "pinia";
import {useActiveUserStore} from "@/store/activeuser.js";
import Apps from "@/components/layout/config/Apps.vue";
import Users from "@/components/layout/config/Users.vue";
export default {
name: "Config",
@ -45,6 +46,11 @@ export default {
component: markRaw(Apps),
props: {isSelected: false},
},
usersTab: {
title: 'Users',
component: markRaw(Users),
props: {isSelected: false},
},
systemLogTab: {
title: 'System log',
component: markRaw(ErrorLog),
@ -86,6 +92,10 @@ export default {
tabs.push(this.appsTab);
}
if (this.activeUserStore.isRightManagement) {
tabs.push(this.usersTab);
}
if (this.activeUserStore.isSuper || this.activeUserStore.isMaterial) {
tabs.push(this.materialsTab);
}

View file

@ -58,7 +58,7 @@ const router = createRouter({
name: 'edit',
beforeEnter: async (to, from) => {
const userStore = useActiveUserStore();
await userStore.loadIfRequired();
await userStore.load();
if (userStore.allowCalculation) {
return true;
@ -72,7 +72,7 @@ const router = createRouter({
name: 'bulk',
beforeEnter: async (to, from) => {
const userStore = useActiveUserStore();
await userStore.loadIfRequired();
await userStore.load();
if (userStore.allowCalculation) {
return true;
@ -86,7 +86,7 @@ const router = createRouter({
name: 'bulk-single-edit',
beforeEnter: async (to, from) => {
const userStore = useActiveUserStore();
await userStore.loadIfRequired();
await userStore.load();
if (userStore.allowCalculation) {
return true;
@ -99,7 +99,7 @@ const router = createRouter({
component: Reporting,
beforeEnter: async (to, from) => {
const userStore = useActiveUserStore();
await userStore.loadIfRequired();
await userStore.load();
if (userStore.allowReporting) {
return true;
@ -112,7 +112,7 @@ const router = createRouter({
component: Config,
beforeEnter: async (to, from) => {
const userStore = useActiveUserStore();
await userStore.loadIfRequired();
await userStore.load();
if (userStore.allowConfiguration) {
return true;

View file

@ -35,6 +35,11 @@ export const useActiveUserStore = defineStore('activeUser', {
return false;
return state.user.groups?.includes("service");
},
isRightManagement(state) {
if (state.user === null)
return false;
return state.user.groups?.includes("right-management");
},
isPackaging(state) {
if (state.user === null)
return false;

View file

@ -41,16 +41,18 @@ export const useNotificationStore = defineStore('notification', {
duration: notification.duration ?? 8000
})
},
async addError(errorDto, options = {}) {
async addError(errorDto, options = {}, silent = false) {
const {request = null, store = null, global = false} = options;
const state = this.captureStoreState(store, global);
if (!silent) {
this.addNotification({
icon: 'bug',
message: errorDto.message ?? 'Unknown error code',
title: errorDto.title ?? 'Unknown error',
variant: 'exception',
});
}
const error = {
error: {
@ -158,17 +160,18 @@ export function setupErrorBuffer() {
const errorStore = useNotificationStore()
//Unhandled Promise Rejections
// window.addEventListener('unhandledrejection', (event) => {
//
// const error = {
// code: "Unhandled rejection",
// title: "Frontend error",
// message: event.reason?.message || 'Unhandled Promise Rejection',
// traceCombined: event.reason?.stack,
// };
//
// errorStore.addError(error, {global: true}).then(r => {} );
// })
window.addEventListener('unhandledrejection', (event) => {
const error = {
code: "Unhandled rejection",
title: "Frontend error",
message: event.reason?.message || 'Unhandled Promise Rejection',
traceCombined: event.reason?.stack,
};
errorStore.addError(error, {global: true}, true).then(r => {
});
})
// // JavaScript Errors
window.addEventListener('error', (event) => {
@ -178,7 +181,8 @@ export function setupErrorBuffer() {
message: event.reason?.message || 'Unhandled Promise Rejection',
traceCombined: event.reason?.stack,
};
errorStore.addError(error, {global: true}).then(r => {} );
errorStore.addError(error, {global: true}).then(r => {
});
})
window.addEventListener('beforeunload', async () => {

View file

@ -0,0 +1,64 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useNotificationStore} from "@/store/notification.js";
import performRequest from "@/backend.js";
import logger from "@/logger.js";
export const useUsersStore = defineStore('users', {
state: () => ({
users: [],
loading: false,
pagination: {},
query: {},
}),
getters: {
getById: (state) => {
return (id) => state.users.find(p => p.id === id)
}
},
actions: {
async setQuery(query) {
this.query = query;
await this.load();
},
async updateUser(user) {
const body = {
firstname: user.firstname,
lastname: user.lastname,
mail: user.mail,
workday_id: user.workday_id,
groups: user.groups
}
await performRequest(this, "PUT", `${config.backendUrl}/users/`, body, false);
},
async load() {
this.loading = true;
const params = new URLSearchParams();
if (this.query?.page)
params.append('page', this.query.page);
if (this.query?.pageSize)
params.append('limit', this.query.pageSize);
const endpoint = '/users';
const url = `${config.backendUrl}${endpoint}/${params.size === 0 ? '' : '?'}${params.toString()}`;
const {data: data, headers: headers} = await performRequest(this, "GET", url, null, true);
this.pagination = {
page: parseInt(headers.get('X-Current-Page')),
pageCount: parseInt(headers.get('X-Page-Count')),
totalCount: parseInt(headers.get('X-Total-Count'))
};
this.loading = false;
this.empty = data.length === 0;
this.users = data;
}
}
});