123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470 |
- <!--
- - @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/>.
- -
- -->
- <template>
- <Fragment>
- <NewUserModal v-if="showConfig.showNewUserForm"
- :loading="loading"
- :new-user="newUser"
- :quota-options="quotaOptions"
- @reset="resetForm"
- @close="closeModal" />
- <NcEmptyContent v-if="filteredUsers.length === 0"
- class="empty"
- :name="isInitialLoad && loading.users ? null : t('settings', 'No users')">
- <template #icon>
- <NcLoadingIcon v-if="isInitialLoad && loading.users"
- :name="t('settings', 'Loading users …')"
- :size="64" />
- <NcIconSvgWrapper v-else
- :svg="usersSvg" />
- </template>
- </NcEmptyContent>
- <RecycleScroller v-else
- ref="scroller"
- class="user-list"
- :style="style"
- :items="filteredUsers"
- key-field="id"
- role="table"
- list-tag="tbody"
- list-class="user-list__body"
- item-tag="tr"
- item-class="user-list__row"
- :item-size="rowHeight"
- @hook:mounted="handleMounted"
- @scroll-end="handleScrollEnd">
- <template #before>
- <caption class="hidden-visually">
- {{ t('settings', 'List of users. This list is not fully rendered for performance reasons. The users will be rendered as you navigate through the list.') }}
- </caption>
- <UserListHeader :has-obfuscated="hasObfuscated" />
- </template>
- <template #default="{ item: user }">
- <UserRow :user="user"
- :users="users"
- :settings="settings"
- :has-obfuscated="hasObfuscated"
- :groups="groups"
- :sub-admins-groups="subAdminsGroups"
- :quota-options="quotaOptions"
- :languages="languages"
- :external-actions="externalActions" />
- </template>
- <template #after>
- <UserListFooter :loading="loading.users"
- :filtered-users="filteredUsers" />
- </template>
- </RecycleScroller>
- </Fragment>
- </template>
- <script>
- import Vue from 'vue'
- import { Fragment } from 'vue-frag'
- import { RecycleScroller } from 'vue-virtual-scroller'
- import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
- import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
- import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
- import { subscribe, unsubscribe } from '@nextcloud/event-bus'
- import { showError } from '@nextcloud/dialogs'
- import NewUserModal from './Users/NewUserModal.vue'
- import UserListFooter from './Users/UserListFooter.vue'
- import UserListHeader from './Users/UserListHeader.vue'
- import UserRow from './Users/UserRow.vue'
- import { defaultQuota, isObfuscated, unlimitedQuota } from '../utils/userUtils.ts'
- import logger from '../logger.js'
- import usersSvg from '../../img/users.svg?raw'
- const newUser = {
- id: '',
- displayName: '',
- password: '',
- mailAddress: '',
- groups: [],
- manager: '',
- subAdminsGroups: [],
- quota: defaultQuota,
- language: {
- code: 'en',
- name: t('settings', 'Default language'),
- },
- }
- export default {
- name: 'UserList',
- components: {
- Fragment,
- NcEmptyContent,
- NcIconSvgWrapper,
- NcLoadingIcon,
- NewUserModal,
- RecycleScroller,
- UserListFooter,
- UserListHeader,
- UserRow,
- },
- props: {
- selectedGroup: {
- type: String,
- default: null,
- },
- externalActions: {
- type: Array,
- default: () => [],
- },
- },
- data() {
- return {
- loading: {
- all: false,
- groups: false,
- users: false,
- },
- isInitialLoad: true,
- rowHeight: 55,
- usersSvg,
- searchQuery: '',
- newUser: Object.assign({}, newUser),
- }
- },
- computed: {
- showConfig() {
- return this.$store.getters.getShowConfig
- },
- settings() {
- return this.$store.getters.getServerData
- },
- 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))
- },
- subAdminsGroups() {
- // data provided php side
- return this.$store.getters.getSubadminGroups
- },
- quotaOptions() {
- // convert the preset array into objects
- const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({
- id: cur,
- label: cur,
- }), [])
- // add default presets
- if (this.settings.allowUnlimitedQuota) {
- quotaPreset.unshift(unlimitedQuota)
- }
- quotaPreset.unshift(defaultQuota)
- return quotaPreset
- },
- usersOffset() {
- return this.$store.getters.getUsersOffset
- },
- usersLimit() {
- return this.$store.getters.getUsersLimit
- },
- usersCount() {
- return this.users.length
- },
- /* LANGUAGES */
- languages() {
- return [
- {
- label: t('settings', 'Common languages'),
- languages: this.settings.languages.commonLanguages,
- },
- {
- label: t('settings', 'Other languages'),
- languages: this.settings.languages.otherLanguages,
- },
- ]
- },
- },
- watch: {
- // watch url change and group select
- async selectedGroup(val, old) {
- this.isInitialLoad = true
- // if selected is the disabled group but it's empty
- await this.redirectIfDisabled()
- this.$store.commit('resetUsers')
- await this.loadUsers()
- this.setNewUserDefaultGroup(val)
- },
- filteredUsers(filteredUsers) {
- logger.debug(`${filteredUsers.length} filtered user(s)`)
- },
- },
- 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'))
- }
- /**
- * Reset and init new user form
- */
- this.resetForm()
- /**
- * Register search
- */
- subscribe('nextcloud:unified-search.search', this.search)
- subscribe('nextcloud:unified-search.reset', this.resetSearch)
- /**
- * If disabled group but empty, redirect
- */
- await this.redirectIfDisabled()
- },
- beforeDestroy() {
- unsubscribe('nextcloud:unified-search.search', this.search)
- unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
- },
- methods: {
- async handleMounted() {
- // Add proper semantics to the recycle scroller slots
- const header = this.$refs.scroller.$refs.before
- const footer = this.$refs.scroller.$refs.after
- header.classList.add('user-list__header')
- header.setAttribute('role', 'rowgroup')
- footer.classList.add('user-list__footer')
- footer.setAttribute('role', 'rowgroup')
- },
- async handleScrollEnd() {
- await this.loadUsers()
- },
- async loadUsers() {
- this.loading.users = true
- try {
- await this.$store.dispatch('getUsers', {
- offset: this.usersOffset,
- limit: this.usersLimit,
- group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '',
- search: this.searchQuery,
- })
- logger.debug(`${this.users.length} total user(s) loaded`)
- } catch (error) {
- logger.error('Failed to load users', { error })
- showError('Failed to load users')
- }
- this.loading.users = false
- this.isInitialLoad = false
- },
- closeModal() {
- this.$store.commit('setShowConfig', {
- key: 'showNewUserForm',
- value: false,
- })
- },
- async search({ query }) {
- this.searchQuery = query
- this.$store.commit('resetUsers')
- await this.loadUsers()
- },
- resetSearch() {
- this.search({ query: '' })
- },
- resetForm() {
- // revert form to original state
- this.newUser = Object.assign({}, newUser)
- /**
- * Init default language from server data. The use of this.settings
- * requires a computed variable, which break the v-model binding of the form,
- * this is a much easier solution than getter and setter on a computed var
- */
- if (this.settings.defaultLanguage) {
- Vue.set(this.newUser.language, 'code', this.settings.defaultLanguage)
- }
- /**
- * In case the user directly loaded the user list within a group
- * the watch won't be triggered. We need to initialize it.
- */
- this.setNewUserDefaultGroup(this.selectedGroup)
- this.loading.all = false
- },
- setNewUserDefaultGroup(value) {
- if (value && value.length > 0) {
- // setting new user default group to the current selected one
- const currentGroup = this.groups.find(group => group.id === value)
- if (currentGroup) {
- this.newUser.groups = [currentGroup]
- return
- }
- }
- // fallback, empty selected group
- this.newUser.groups = []
- },
- /**
- * 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
- */
- 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' })
- await this.loadUsers()
- }
- },
- },
- }
- </script>
- <style lang="scss" scoped>
- @import './Users/shared/styles.scss';
- .empty {
- :deep {
- .icon-vue {
- width: 64px;
- height: 64px;
- svg {
- max-width: 64px;
- max-height: 64px;
- }
- }
- }
- }
- .user-list {
- --avatar-cell-width: 48px;
- --cell-padding: 7px;
- --cell-width: 200px;
- --cell-min-width: calc(var(--cell-width) - (2 * var(--cell-padding)));
- display: block;
- overflow: auto;
- height: 100%;
- :deep {
- .user-list {
- &__body {
- display: flex;
- flex-direction: column;
- width: 100%;
- // Necessary for virtual scrolling absolute
- position: relative;
- margin-top: var(--row-height);
- }
- &__row {
- @include row;
- border-bottom: 1px solid var(--color-border);
- &:hover {
- background-color: var(--color-background-hover);
- .row__cell:not(.row__cell--actions) {
- background-color: var(--color-background-hover);
- }
- }
- }
- }
- .vue-recycle-scroller__slot {
- &.user-list__header,
- &.user-list__footer {
- position: sticky;
- }
- &.user-list__header {
- top: 0;
- z-index: 10;
- }
- &.user-list__footer {
- left: 0;
- }
- }
- }
- }
- </style>
|