aboutsummaryrefslogtreecommitdiffstats
path: root/apps/settings/src/components/UserList.vue
diff options
context:
space:
mode:
Diffstat (limited to 'apps/settings/src/components/UserList.vue')
-rw-r--r--apps/settings/src/components/UserList.vue657
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>