diff options
Diffstat (limited to 'apps/settings/src/components/UserList.vue')
-rw-r--r-- | apps/settings/src/components/UserList.vue | 657 |
1 files changed, 214 insertions, 443 deletions
diff --git a/apps/settings/src/components/UserList.vue b/apps/settings/src/components/UserList.vue index 4d50da62596..459548fad26 100644 --- a/apps/settings/src/components/UserList.vue +++ b/apps/settings/src/components/UserList.vue @@ -1,285 +1,112 @@ <!-- - - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div id="app-content" class="user-list-grid" @scroll.passive="onScroll"> - <Modal v-if="showConfig.showNewUserForm" size="small" @close="closeModal"> - <form id="new-user" - :disabled="loading.all" - class="modal__content" - @submit.prevent="createUser"> - <h2>{{ t('settings','New user') }}</h2> - <input id="newusername" - ref="newusername" - v-model="newUser.id" - :disabled="settings.newUserGenerateUserID" - :placeholder="settings.newUserGenerateUserID - ? t('settings', 'Will be autogenerated') - : t('settings', 'Username')" - autocapitalize="none" - autocomplete="off" - autocorrect="off" - class="modal__item" - name="username" - pattern="[a-zA-Z0-9 _\.@\-']+" - required - type="text"> - <input id="newdisplayname" - v-model="newUser.displayName" - :placeholder="t('settings', 'Display name')" - autocapitalize="none" - autocomplete="off" - autocorrect="off" - class="modal__item" - name="displayname" - type="text"> - <input id="newuserpassword" - ref="newuserpassword" - v-model="newUser.password" - :minlength="minPasswordLength" - :placeholder="t('settings', 'Password')" - :required="newUser.mailAddress===''" - autocapitalize="none" - autocomplete="new-password" - autocorrect="off" - class="modal__item" - name="password" - type="password"> - <input id="newemail" - v-model="newUser.mailAddress" - :placeholder="t('settings', 'Email')" - :required="newUser.password==='' || settings.newUserRequireEmail" - autocapitalize="none" - autocomplete="off" - autocorrect="off" - class="modal__item" - name="email" - type="email"> - <div class="groups modal__item"> - <!-- hidden input trick for vanilla html5 form validation --> - <input v-if="!settings.isAdmin" - id="newgroups" - :class="{'icon-loading-small': loading.groups}" - :required="!settings.isAdmin" - :value="newUser.groups" - tabindex="-1" - type="text"> - <Multiselect v-model="newUser.groups" - :close-on-select="false" - :disabled="loading.groups||loading.all" - :multiple="true" - :options="canAddGroups" - :placeholder="t('settings', 'Add user to group')" - :tag-width="60" - :taggable="true" - class="multiselect-vue" - label="name" - tag-placeholder="create" - track-by="id" - @tag="createGroup"> - <!-- If user is not admin, he is a subadmin. - Subadmins can't create users outside their groups - Therefore, empty select is forbidden --> - <span slot="noResult">{{ t('settings', 'No results') }}</span> - </Multiselect> - </div> - <div v-if="subAdminsGroups.length>0 && settings.isAdmin" - class="subadmins modal__item"> - <Multiselect v-model="newUser.subAdminsGroups" - :close-on-select="false" - :multiple="true" - :options="subAdminsGroups" - :placeholder="t('settings', 'Set user as admin for')" - :tag-width="60" - class="multiselect-vue" - label="name" - track-by="id"> - <span slot="noResult">{{ t('settings', 'No results') }}</span> - </Multiselect> - </div> - <div class="quota modal__item"> - <Multiselect v-model="newUser.quota" - :allow-empty="false" - :options="quotaOptions" - :placeholder="t('settings', 'Select user quota')" - :taggable="true" - class="multiselect-vue" - label="label" - track-by="id" - @tag="validateQuota" /> - </div> - <div v-if="showConfig.showLanguages" class="languages modal__item"> - <Multiselect v-model="newUser.language" - :allow-empty="false" - :options="languages" - :placeholder="t('settings', 'Default language')" - class="multiselect-vue" - group-label="label" - group-values="languages" - label="name" - track-by="code" /> - </div> - <div v-if="showConfig.showStoragePath" class="storageLocation" /> - <div v-if="showConfig.showUserBackend" class="userBackend" /> - <div v-if="showConfig.showLastLogin" class="lastLogin" /> - <div class="user-actions"> - <Button id="newsubmit" - type="primary" - native-type="submit" - value=""> - {{ t('settings', 'Add a new user') }} - </Button> - </div> - </form> - </Modal> - <div id="grid-header" - :class="{'sticky': scrolled && !showConfig.showNewUserForm}" - class="row"> - <div id="headerAvatar" class="avatar" /> - <div id="headerName" class="name"> - {{ t('settings', 'Username') }} - - <div class="subtitle"> - {{ t('settings', 'Display name') }} - </div> - </div> - <div id="headerPassword" class="password"> - {{ t('settings', 'Password') }} - </div> - <div id="headerAddress" class="mailAddress"> - {{ t('settings', 'Email') }} - </div> - <div id="headerGroups" class="groups"> - {{ t('settings', 'Groups') }} - </div> - <div v-if="subAdminsGroups.length>0 && settings.isAdmin" - id="headerSubAdmins" - class="subadmins"> - {{ t('settings', 'Group admin for') }} - </div> - <div id="headerQuota" class="quota"> - {{ t('settings', 'Quota') }} - </div> - <div v-if="showConfig.showLanguages" - id="headerLanguages" - class="languages"> - {{ t('settings', 'Language') }} - </div> - - <div v-if="showConfig.showUserBackend || showConfig.showStoragePath" - class="headerUserBackend userBackend"> - <div v-if="showConfig.showUserBackend" class="userBackend"> - {{ t('settings', 'User backend') }} - </div> - <div v-if="showConfig.showStoragePath" - class="subtitle storageLocation"> - {{ t('settings', 'Storage location') }} - </div> - </div> - <div v-if="showConfig.showLastLogin" - class="headerLastLogin lastLogin"> - {{ t('settings', 'Last login') }} - </div> - - <div class="userActions" /> - </div> - - <user-row v-for="user in filteredUsers" - :key="user.id" - :external-actions="externalActions" - :groups="groups" - :languages="languages" + <Fragment> + <NewUserDialog v-if="showConfig.showNewUserForm" + :loading="loading" + :new-user="newUser" :quota-options="quotaOptions" - :settings="settings" - :show-config="showConfig" - :sub-admins-groups="subAdminsGroups" - :user="user" /> - <InfiniteLoading ref="infiniteLoading" @infinite="infiniteHandler"> - <div slot="spinner"> - <div class="users-icon-loading icon-loading" /> - </div> - <div slot="no-more"> - <div class="users-list-end" /> - </div> - <div slot="no-results"> - <div id="emptycontent"> - <div class="icon-contacts-dark" /> - <h2>{{ t('settings', 'No users in here') }}</h2> - </div> - </div> - </InfiniteLoading> - </div> + @reset="resetForm" + @closing="closeDialog" /> + + <NcEmptyContent v-if="filteredUsers.length === 0" + class="empty" + :name="isInitialLoad && loading.users ? null : t('settings', 'No accounts')"> + <template #icon> + <NcLoadingIcon v-if="isInitialLoad && loading.users" + :name="t('settings', 'Loading accounts …')" + :size="64" /> + <NcIconSvgWrapper v-else :path="mdiAccountGroupOutline" :size="64" /> + </template> + </NcEmptyContent> + + <VirtualList v-else + :data-component="UserRow" + :data-sources="filteredUsers" + data-key="id" + data-cy-user-list + :item-height="rowHeight" + :style="style" + :extra-props="{ + users, + settings, + hasObfuscated, + quotaOptions, + languages, + externalActions, + }" + @scroll-end="handleScrollEnd"> + <template #before> + <caption class="hidden-visually"> + {{ t('settings', 'List of accounts. This list is not fully rendered for performance reasons. The accounts will be rendered as you navigate through the list.') }} + </caption> + </template> + + <template #header> + <UserListHeader :has-obfuscated="hasObfuscated" /> + </template> + + <template #footer> + <UserListFooter :loading="loading.users" + :filtered-users="filteredUsers" /> + </template> + </VirtualList> + </Fragment> </template> <script> +import { mdiAccountGroupOutline } from '@mdi/js' +import { showError } from '@nextcloud/dialogs' import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import InfiniteLoading from 'vue-infinite-loading' +import { Fragment } from 'vue-frag' + import Vue from 'vue' -import Modal from '@nextcloud/vue/dist/Components/Modal' -import Button from '@nextcloud/vue/dist/Components/Button' -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' -import userRow from './UserList/UserRow' +import VirtualList from './Users/VirtualList.vue' +import NewUserDialog from './Users/NewUserDialog.vue' +import UserListFooter from './Users/UserListFooter.vue' +import UserListHeader from './Users/UserListHeader.vue' +import UserRow from './Users/UserRow.vue' -const unlimitedQuota = { - id: 'none', - label: t('settings', 'Unlimited'), -} -const defaultQuota = { - id: 'default', - label: t('settings', 'Default quota'), -} -const newUser = { +import { defaultQuota, isObfuscated, unlimitedQuota } from '../utils/userUtils.ts' +import logger from '../logger.ts' + +const newUser = Object.freeze({ id: '', displayName: '', password: '', mailAddress: '', groups: [], + manager: '', subAdminsGroups: [], quota: defaultQuota, language: { code: 'en', name: t('settings', 'Default language'), }, -} +}) export default { name: 'UserList', + components: { - Modal, - userRow, - Multiselect, - InfiniteLoading, - Button, + Fragment, + NcEmptyContent, + NcIconSvgWrapper, + NcLoadingIcon, + NewUserDialog, + UserListFooter, + UserListHeader, + VirtualList, }, + props: { - users: { - type: Array, - default: () => [], - }, - showConfig: { - type: Object, - required: true, - }, selectedGroup: { type: String, default: null, @@ -289,56 +116,65 @@ export default { default: () => [], }, }, + + setup() { + // non reactive properties + return { + mdiAccountGroupOutline, + rowHeight: 55, + + UserRow, + } + }, + data() { return { - unlimitedQuota, - defaultQuota, loading: { all: false, groups: false, + users: false, }, - scrolled: false, + newUser: { ...newUser }, + isInitialLoad: true, searchQuery: '', - newUser: Object.assign({}, newUser), } }, + computed: { + showConfig() { + return this.$store.getters.getShowConfig + }, + settings() { return this.$store.getters.getServerData }, - selectedGroupDecoded() { - return decodeURIComponent(this.selectedGroup) + + style() { + return { + '--row-height': `${this.rowHeight}px`, + } + }, + + hasObfuscated() { + return this.filteredUsers.some(user => isObfuscated(user)) + }, + + users() { + return this.$store.getters.getUsers }, + filteredUsers() { if (this.selectedGroup === 'disabled') { return this.users.filter(user => user.enabled === false) } - if (!this.settings.isAdmin) { - // we don't want subadmins to edit themselves - return this.users.filter(user => user.enabled !== false) - } return this.users.filter(user => user.enabled !== false) }, + groups() { - // data provided php side + remove the disabled group - return this.$store.getters.getGroups - .filter(group => group.id !== 'disabled') - .sort((a, b) => a.name.localeCompare(b.name)) - }, - canAddGroups() { - // disabled if no permission to add new users to group - return this.groups.map(group => { - // clone object because we don't want - // to edit the original groups - group = Object.assign({}, group) - group.$isDisabled = group.canAdd === false - return group - }) - }, - subAdminsGroups() { - // data provided php side - return this.$store.getters.getSubadminGroups + return this.$store.getters.getSortedGroups + .filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled') }, + quotaOptions() { // convert the preset array into objects const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({ @@ -347,20 +183,28 @@ export default { }), []) // add default presets if (this.settings.allowUnlimitedQuota) { - quotaPreset.unshift(this.unlimitedQuota) + quotaPreset.unshift(unlimitedQuota) } - quotaPreset.unshift(this.defaultQuota) + quotaPreset.unshift(defaultQuota) return quotaPreset }, - minPasswordLength() { - return this.$store.getters.getPasswordPolicyMinLength - }, + usersOffset() { return this.$store.getters.getUsersOffset }, + usersLimit() { return this.$store.getters.getUsersLimit }, + + disabledUsersOffset() { + return this.$store.getters.getDisabledUsersOffset + }, + + disabledUsersLimit() { + return this.$store.getters.getDisabledUsersLimit + }, + usersCount() { return this.users.length }, @@ -379,32 +223,28 @@ export default { ] }, }, + watch: { // watch url change and group select - selectedGroup(val, old) { + async selectedGroup(val) { + this.isInitialLoad = true // if selected is the disabled group but it's empty - this.redirectIfDisabled() + await this.redirectIfDisabled() this.$store.commit('resetUsers') - this.$refs.infiniteLoading.stateChanger.reset() + await this.loadUsers() this.setNewUserDefaultGroup(val) }, - // make sure the infiniteLoading state is changed if we manually - // add/remove data from the store - usersCount(val, old) { - // deleting the last user, reset the list - if (val === 0 && old === 1) { - this.$refs.infiniteLoading.stateChanger.reset() - // adding the first user, warn the infiniteLoader that - // the list is not empty anymore (we don't fetch the newly - // added user as we already have all the info we need) - } else if (val === 1 && old === 0) { - this.$refs.infiniteLoading.stateChanger.loaded() - } + filteredUsers(filteredUsers) { + logger.debug(`${filteredUsers.length} filtered user(s)`) }, }, - mounted() { + async created() { + await this.loadUsers() + }, + + async mounted() { if (!this.settings.canChangePassword) { OC.Notification.showTemporary(t('settings', 'Password change is disabled because the master key is disabled')) } @@ -423,61 +263,64 @@ export default { /** * If disabled group but empty, redirect */ - this.redirectIfDisabled() + await this.redirectIfDisabled() }, + beforeDestroy() { unsubscribe('nextcloud:unified-search.search', this.search) unsubscribe('nextcloud:unified-search.reset', this.resetSearch) }, methods: { - onScroll(event) { - this.scrolled = event.target.scrollTo > 0 + async handleScrollEnd() { + await this.loadUsers() }, - /** - * Validate quota string to make sure it's a valid human file size - * - * @param {string} quota Quota in readable format '5 GB' - * @return {object} - */ - validateQuota(quota) { - // only used for new presets sent through @Tag - const validQuota = OC.Util.computerFileSize(quota) - if (validQuota !== null && validQuota >= 0) { - // unify format output - quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota)) - this.newUser.quota = { id: quota, label: quota } - return this.newUser.quota + async loadUsers() { + this.loading.users = true + try { + if (this.selectedGroup === 'disabled') { + await this.$store.dispatch('getDisabledUsers', { + offset: this.disabledUsersOffset, + limit: this.disabledUsersLimit, + search: this.searchQuery, + }) + } else if (this.selectedGroup === '__nc_internal_recent') { + await this.$store.dispatch('getRecentUsers', { + offset: this.usersOffset, + limit: this.usersLimit, + search: this.searchQuery, + }) + } else { + await this.$store.dispatch('getUsers', { + offset: this.usersOffset, + limit: this.usersLimit, + group: this.selectedGroup, + search: this.searchQuery, + }) + } + logger.debug(`${this.users.length} total user(s) loaded`) + } catch (error) { + logger.error('Failed to load accounts', { error }) + showError('Failed to load accounts') } - // Default is unlimited - this.newUser.quota = this.quotaOptions[0] - return this.quotaOptions[0] + this.loading.users = false + this.isInitialLoad = false }, - infiniteHandler($state) { - this.$store.dispatch('getUsers', { - offset: this.usersOffset, - limit: this.usersLimit, - group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '', - search: this.searchQuery, + closeDialog() { + this.$store.commit('setShowConfig', { + key: 'showNewUserForm', + value: false, }) - .then((usersCount) => { - if (usersCount > 0) { - $state.loaded() - } - if (usersCount < this.usersLimit) { - $state.complete() - } - }) }, - /* SEARCH */ - search({ query }) { + async search({ query }) { this.searchQuery = query this.$store.commit('resetUsers') - this.$refs.infiniteLoading.stateChanger.reset() + await this.loadUsers() }, + resetSearch() { this.search({ query: '' }) }, @@ -503,40 +346,21 @@ export default { this.loading.all = false }, - createUser() { - this.loading.all = true - this.$store.dispatch('addUser', { - userid: this.newUser.id, - password: this.newUser.password, - displayName: this.newUser.displayName, - email: this.newUser.mailAddress, - groups: this.newUser.groups.map(group => group.id), - subadmin: this.newUser.subAdminsGroups.map(group => group.id), - quota: this.newUser.quota.id, - language: this.newUser.language.code, - }) - .then(() => { - this.resetForm() - this.$refs.newusername.focus() - this.closeModal() - }) - .catch((error) => { - this.loading.all = false - if (error.response && error.response.data && error.response.data.ocs && error.response.data.ocs.meta) { - const statuscode = error.response.data.ocs.meta.statuscode - if (statuscode === 102) { - // wrong username - this.$refs.newusername.focus() - } else if (statuscode === 107) { - // wrong password - this.$refs.newuserpassword.focus() - } - } - }) - }, + setNewUserDefaultGroup(value) { - if (value && value.length > 0) { - // setting new user default group to the current selected one + // Is no value set, but user is a line manager we set their group as this is a requirement for line manager + if (!value && !this.settings.isAdmin && !this.settings.isDelegatedAdmin) { + const groups = this.$store.getters.getSubAdminGroups + // if there are multiple groups we do not know which to add, + // so we cannot make the managers life easier by preselecting it. + if (groups.length === 1) { + this.newUser.groups = [...groups] + } + return + } + + if (value) { + // setting new account default group to the current selected one const currentGroup = this.groups.find(group => group.id === value) if (currentGroup) { this.newUser.groups = [currentGroup] @@ -548,91 +372,38 @@ export default { }, /** - * Create a new group - * - * @param {string} gid Group id - * @return {Promise} - */ - createGroup(gid) { - this.loading.groups = true - this.$store.dispatch('addGroup', gid) - .then((group) => { - this.newUser.groups.push(this.groups.find(group => group.id === gid)) - this.loading.groups = false - }) - .catch(() => { - this.loading.groups = false - }) - return this.$store.getters.getGroups[this.groups.length] - }, - - /** * If the selected group is the disabled group but the count is 0 * redirect to the all users page. * we only check for 0 because we don't have the count on ldap * and we therefore set the usercount to -1 in this specific case */ - redirectIfDisabled() { + async redirectIfDisabled() { const allGroups = this.$store.getters.getGroups if (this.selectedGroup === 'disabled' && allGroups.findIndex(group => group.id === 'disabled' && group.usercount === 0) > -1) { // disabled group is empty, redirection to all users this.$router.push({ name: 'users' }) - this.$refs.infiniteLoading.stateChanger.reset() + await this.loadUsers() } }, - closeModal() { - // eslint-disable-next-line vue/no-mutating-props - this.showConfig.showNewUserForm = false - }, }, } </script> -<style scoped> - .modal-wrapper { - margin: 2vh 0; - align-items: flex-start; - } - .modal__content { - display: flex; - padding: 20px; - flex-direction: column; - align-items: center; - text-align: center; - } - .modal__item { - margin-bottom: 16px; - width: 100%; - } - .modal__item:not(:focus):not(:active) { - border-color: var(--color-border-dark); - } - .modal__item::v-deep .multiselect { - width: 100%; - } - .user-actions { - margin-top: 20px; - } - .modal__content::v-deep .multiselect__single { - text-align: left; - box-sizing: border-box; - } - .modal__content::v-deep .multiselect__content-wrapper { - box-sizing: border-box; - } - .row::v-deep .multiselect__single { - z-index: auto !important; - } - /* fake input for groups validation */ - input#newgroups { - position: absolute; - opacity: 0; - /* The "hidden" input is behind the Multiselect, so in general it does - * not receives clicks. However, with Firefox, after the validation - * fails, it will receive the first click done on it, so its width needs - * to be set to 0 to prevent that ("pointer-events: none" does not - * prevent it). */ - width: 0; +<style lang="scss" scoped> +@use './Users/shared/styles' as *; + +.empty { + :deep { + .icon-vue { + width: 64px; + height: 64px; + + svg { + max-width: 64px; + max-height: 64px; + } + } } +} </style> |