<!-- - @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> <div id="app-content" class="user-list-grid" @scroll.passive="onScroll"> <form v-show="showConfig.showNewUserForm" id="new-user" :class="{'sticky': scrolled && showConfig.showNewUserForm}" :disabled="loading.all" class="row" @submit.prevent="createUser"> <div :class="loading.all?'icon-loading-small':'icon-add'" /> <div class="name"> <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" name="username" pattern="[a-zA-Z0-9 _\.@\-']+" required type="text"> <div class="displayName"> <input id="newdisplayname" v-model="newUser.displayName" :placeholder="t('settings', 'Display name')" autocapitalize="none" autocomplete="off" autocorrect="off" name="displayname" type="text"> </div> </div> <div class="password"> <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" name="password" type="password"> </div> <div class="mailAddress"> <input id="newemail" v-model="newUser.mailAddress" :placeholder="t('settings', 'Email')" :required="newUser.password==='' || settings.newUserRequireEmail" autocapitalize="none" autocomplete="off" autocorrect="off" name="email" type="email"> </div> <div class="groups"> <!-- 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 in 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"> <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"> <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"> <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="userActions"> <input id="newsubmit" :title="t('settings', 'Add a new user')" class="button primary icon-checkmark-white has-tooltip" type="submit" value=""> <div class="closeButton"> <Actions> <ActionButton icon="icon-close" @click="onClose"> {{ t('settings', 'Close') }} </ActionButton> </Actions> </div> </div> </form> <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, key) in filteredUsers" :key="key" :external-actions="externalActions" :groups="groups" :languages="languages" :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> </template> <script> import userRow from './UserList/UserRow' import { Multiselect, Actions, ActionButton } from '@nextcloud/vue' import InfiniteLoading from 'vue-infinite-loading' import Vue from 'vue' const unlimitedQuota = { id: 'none', label: t('settings', 'Unlimited'), } const defaultQuota = { id: 'default', label: t('settings', 'Default quota'), } const newUser = { id: '', displayName: '', password: '', mailAddress: '', groups: [], subAdminsGroups: [], quota: defaultQuota, language: { code: 'en', name: t('settings', 'Default language'), }, } export default { name: 'UserList', components: { userRow, Multiselect, InfiniteLoading, Actions, ActionButton, }, props: { users: { type: Array, default: () => [], }, showConfig: { type: Object, required: true, }, selectedGroup: { type: String, default: null, }, externalActions: { type: Array, default: () => [], }, }, data() { return { unlimitedQuota, defaultQuota, loading: { all: false, groups: false, }, scrolled: false, searchQuery: '', newUser: Object.assign({}, newUser), } }, computed: { settings() { return this.$store.getters.getServerData }, selectedGroupDecoded() { return decodeURIComponent(this.selectedGroup) }, 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 }, quotaOptions() { // convert the preset array into objects const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({ id: cur, label: cur, }), []) // add default presets quotaPreset.unshift(this.unlimitedQuota) quotaPreset.unshift(this.defaultQuota) return quotaPreset }, minPasswordLength() { return this.$store.getters.getPasswordPolicyMinLength }, 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', 'All languages'), languages: this.settings.languages.languages, }, ] }, }, watch: { // watch url change and group select selectedGroup: function(val, old) { // if selected is the disabled group but it's empty this.redirectIfDisabled() this.$store.commit('resetUsers') this.$refs.infiniteLoading.stateChanger.reset() this.setNewUserDefaultGroup(val) }, // make sure the infiniteLoading state is changed if we manually // add/remove data from the store usersCount: function(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() } }, }, 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 */ this.userSearch = new OCA.Search(this.search, this.resetSearch) /** * If disabled group but empty, redirect */ this.redirectIfDisabled() }, methods: { onScroll(event) { this.scrolled = event.target.scrollTo > 0 }, /** * Validate quota string to make sure it's a valid human file size * * @param {string} quota Quota in readable format '5 GB' * @returns {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 } // Default is unlimited this.newUser.quota = this.quotaOptions[0] return this.quotaOptions[0] }, infiniteHandler($state) { this.$store.dispatch('getUsers', { offset: this.usersOffset, limit: this.usersLimit, group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '', search: this.searchQuery, }) .then((response) => { response ? $state.loaded() : $state.complete() }) }, /* SEARCH */ search(query) { this.searchQuery = query this.$store.commit('resetUsers') this.$refs.infiniteLoading.stateChanger.reset() }, resetSearch() { this.search('') }, 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 }, 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() }) .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 const currentGroup = this.groups.find(group => group.id === value) if (currentGroup) { this.newUser.groups = [currentGroup] return } } // fallback, empty selected group this.newUser.groups = [] }, /** * Create a new group * * @param {string} gid Group id * @returns {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() { 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() } }, onClose() { this.showConfig.showNewUserForm = false }, }, } </script> <style scoped> .row::v-deep .multiselect__single { z-index: auto !important; } </style>