diff options
Diffstat (limited to 'apps/settings/src/components/Users/UserRow.vue')
-rw-r--r-- | apps/settings/src/components/Users/UserRow.vue | 350 |
1 files changed, 215 insertions, 135 deletions
diff --git a/apps/settings/src/components/Users/UserRow.vue b/apps/settings/src/components/Users/UserRow.vue index a27b41e11a6..43668725972 100644 --- a/apps/settings/src/components/Users/UserRow.vue +++ b/apps/settings/src/components/Users/UserRow.vue @@ -1,34 +1,14 @@ <!-- - - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> - - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - @author Gary Kim <gary@garykim.dev> - - @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> <tr class="user-list__row" :data-cy-user-row="user.id"> <td class="row__cell row__cell--avatar" data-cy-user-list-cell-avatar> <NcLoadingIcon v-if="isLoadingUser" - :name="t('settings', 'Loading user …')" + :name="t('settings', 'Loading account …')" :size="32" /> <NcAvatar v-else-if="visible" disable-menu @@ -54,13 +34,14 @@ spellcheck="false" @trailing-button-click="updateDisplayName" /> </template> - <template v-else> - <strong v-if="!isObfuscated" - :title="user.displayname?.length > 20 ? user.displayname : null"> - {{ user.displayname }} - </strong> - <span class="row__subtitle">{{ user.id }}</span> - </template> + <strong v-else-if="!isObfuscated" + :title="user.displayname?.length > 20 ? user.displayname : null"> + {{ user.displayname }} + </strong> + </td> + + <td class="row__cell row__cell--username" data-cy-user-list-cell-username> + <span class="row__subtitle">{{ user.id }}</span> </td> <td data-cy-user-list-cell-password @@ -87,7 +68,7 @@ @trailing-button-click="updatePassword" /> </template> <span v-else-if="isObfuscated"> - {{ t('settings', 'You do not have permissions to see the details of this user') }} + {{ t('settings', 'You do not have permissions to see the details of this account') }} </span> </td> @@ -119,23 +100,24 @@ <template v-if="editing"> <label class="hidden-visually" :for="'groups' + uniqueId"> - {{ t('settings', 'Add user to group') }} + {{ t('settings', 'Add account to group') }} </label> <NcSelect data-cy-user-list-input-groups :data-loading="loading.groups || undefined" :input-id="'groups' + uniqueId" :close-on-select="false" - :disabled="isLoadingField" + :disabled="isLoadingField || loading.groupsDetails" :loading="loading.groups" :multiple="true" :append-to-body="false" :options="availableGroups" - :placeholder="t('settings', 'Add user to group')" - :taggable="settings.isAdmin" + :placeholder="t('settings', 'Add account to group')" + :taggable="settings.isAdmin || settings.isDelegatedAdmin" :value="userGroups" label="name" :no-wrap="true" - :create-option="(value) => ({ name: value, isCreating: true })" + :create-option="(value) => ({ id: value, name: value, isCreating: true })" + @search="searchGroups" @option:created="createGroup" @option:selected="options => addUserGroup(options.at(-1))" @option:deselected="removeUserGroup" /> @@ -146,33 +128,34 @@ </span> </td> - <td v-if="subAdminsGroups.length > 0 && settings.isAdmin" + <td v-if="settings.isAdmin || settings.isDelegatedAdmin" data-cy-user-list-cell-subadmins class="row__cell row__cell--large row__cell--multiline"> - <template v-if="editing && settings.isAdmin && subAdminsGroups.length > 0"> + <template v-if="editing && (settings.isAdmin || settings.isDelegatedAdmin)"> <label class="hidden-visually" :for="'subadmins' + uniqueId"> - {{ t('settings', 'Set user as admin for') }} + {{ t('settings', 'Set account as admin for') }} </label> <NcSelect data-cy-user-list-input-subadmins :data-loading="loading.subadmins || undefined" :input-id="'subadmins' + uniqueId" :close-on-select="false" - :disabled="isLoadingField" + :disabled="isLoadingField || loading.subAdminGroupsDetails" :loading="loading.subadmins" label="name" :append-to-body="false" :multiple="true" :no-wrap="true" - :options="subAdminsGroups" - :placeholder="t('settings', 'Set user as admin for')" - :value="userSubAdminsGroups" + :options="availableSubAdminGroups" + :placeholder="t('settings', 'Set account as admin for')" + :value="userSubAdminGroups" + @search="searchGroups" @option:deselected="removeUserSubAdmin" @option:selected="options => addUserSubAdmin(options.at(-1))" /> </template> <span v-else-if="!isObfuscated" - :title="userSubAdminsGroupsLabels?.length > 40 ? userSubAdminsGroupsLabels : null"> - {{ userSubAdminsGroupsLabels }} + :title="userSubAdminGroupsLabels?.length > 40 ? userSubAdminGroupsLabels : null"> + {{ userSubAdminGroupsLabels }} </span> </td> @@ -180,7 +163,7 @@ <template v-if="editing"> <label class="hidden-visually" :for="'quota' + uniqueId"> - {{ t('settings', 'Select user quota') }} + {{ t('settings', 'Select account quota') }} </label> <NcSelect v-model="editedUserQuota" :close-on-select="true" @@ -193,7 +176,7 @@ :clearable="false" :input-id="'quota' + uniqueId" :options="quotaOptions" - :placeholder="t('settings', 'Select user quota')" + :placeholder="t('settings', 'Select account quota')" :taggable="true" @option:selected="setUserQuota" /> </template> @@ -248,6 +231,12 @@ </template> </td> + <td v-if="showConfig.showFirstLogin" + class="row__cell" + data-cy-user-list-cell-first-login> + <span v-if="!isObfuscated">{{ userFirstLogin }}</span> + </td> + <td v-if="showConfig.showLastLogin" :title="userLastLoginTooltip" class="row__cell" @@ -266,16 +255,17 @@ data-cy-user-list-input-manager :data-loading="loading.manager || undefined" :input-id="'manager' + uniqueId" - :close-on-select="true" :disabled="isLoadingField" - :append-to-body="false" :loading="loadingPossibleManagers || loading.manager" - label="displayname" :options="possibleManagers" :placeholder="managerLabel" + label="displayname" + :filterable="false" + :internal-search="false" + :clearable="true" @open="searchInitialUserManager" @search="searchUserManager" - @option:selected="updateUserManager" /> + @update:model-value="updateUserManager" /> </template> <span v-else-if="!isObfuscated"> {{ user.manager }} @@ -297,17 +287,20 @@ import { formatFileSize, parseFileSize } from '@nextcloud/files' import { getCurrentUser } from '@nextcloud/auth' import { showSuccess, showError } from '@nextcloud/dialogs' +import { confirmPassword } from '@nextcloud/password-confirmation' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcProgressBar from '@nextcloud/vue/components/NcProgressBar' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcTextField from '@nextcloud/vue/components/NcTextField' import UserRowActions from './UserRowActions.vue' import UserRowMixin from '../../mixins/UserRowMixin.js' -import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts'; +import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts' +import { searchGroups, loadUserGroups, loadUserSubAdminGroups } from '../../service/groups.ts' +import logger from '../../logger.ts' export default { name: 'UserRow', @@ -342,14 +335,6 @@ export default { type: Boolean, required: true, }, - groups: { - type: Array, - default: () => [], - }, - subAdminsGroups: { - type: Array, - required: true, - }, quotaOptions: { type: Array, required: true, @@ -382,6 +367,8 @@ export default { password: false, mailAddress: false, groups: false, + groupsDetails: false, + subAdminGroupsDetails: false, subadmins: false, quota: false, delete: false, @@ -393,13 +380,15 @@ export default { editedDisplayName: this.user.displayname, editedPassword: '', editedMail: this.user.email ?? '', + // Cancelable promise for search groups request + promise: null, } }, computed: { managerLabel() { - // TRANSLATORS This string describes a manager in the context of an organization - return t('settings', 'Set user manager') + // TRANSLATORS This string describes a person's manager in the context of an organization + return t('settings', 'Set line manager') }, isObfuscated() { @@ -422,15 +411,35 @@ export default { return encodeURIComponent(this.user.id + this.rand) }, + availableGroups() { + const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin) + ? this.$store.getters.getSortedGroups + : this.$store.getters.getSubAdminGroups + + return groups.filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled') + }, + + availableSubAdminGroups() { + return this.availableGroups.filter(group => group.id !== 'admin') + }, + userGroupsLabels() { return this.userGroups - .map(group => group.name) + .map(group => { + // Try to match with more extensive group data + const availableGroup = this.availableGroups.find(g => g.id === group.id) + return availableGroup?.name ?? group.name ?? group.id + }) .join(', ') }, - userSubAdminsGroupsLabels() { - return this.userSubAdminsGroups - .map(group => group.name) + userSubAdminGroupsLabels() { + return this.userSubAdminGroups + .map(group => { + // Try to match with more extensive group data + const availableGroup = this.availableSubAdminGroups.find(g => g.id === group.id) + return availableGroup?.name ?? group.name ?? group.id + }) .join(', ') }, @@ -442,7 +451,7 @@ export default { }, canEdit() { - return getCurrentUser().uid !== this.user.id || this.settings.isAdmin + return getCurrentUser().uid !== this.user.id || this.settings.isAdmin || this.settings.isDelegatedAdmin }, userQuota() { @@ -469,17 +478,17 @@ export default { const actions = [ { icon: 'icon-delete', - text: t('settings', 'Delete user'), + text: t('settings', 'Delete account'), action: this.deleteUser, }, { icon: 'icon-delete', - text: t('settings', 'Wipe all devices'), + text: t('settings', 'Disconnect all devices and delete local data'), action: this.wipeUserDevices, }, { icon: this.user.enabled ? 'icon-close' : 'icon-add', - text: this.user.enabled ? t('settings', 'Disable user') : t('settings', 'Enable user'), + text: this.user.enabled ? t('settings', 'Disable account') : t('settings', 'Enable account'), action: this.enableDisableUser, }, ] @@ -514,7 +523,6 @@ export default { return this.languages[0].languages.concat(this.languages[1].languages) }, }, - async beforeMount() { if (this.user.manager) { await this.initManager(this.user.manager) @@ -522,8 +530,9 @@ export default { }, methods: { - wipeUserDevices() { + async wipeUserDevices() { const userid = this.user.id + await confirmPassword() OC.dialogs.confirmDestructive( t('settings', 'In case of lost device or exiting the organization, this can remotely wipe the Nextcloud data from all devices associated with {userid}. Only works if the devices are connected to the internet.', { userid }), t('settings', 'Remote wipe of devices'), @@ -565,6 +574,66 @@ export default { this.loadingPossibleManagers = false }, + async loadGroupsDetails() { + this.loading.groups = true + this.loading.groupsDetails = true + try { + const groups = await loadUserGroups({ userId: this.user.id }) + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + this.selectedGroups = this.selectedGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup) + } catch (error) { + logger.error(t('settings', 'Failed to load groups with details'), { error }) + } + this.loading.groups = false + this.loading.groupsDetails = false + }, + + async loadSubAdminGroupsDetails() { + this.loading.subadmins = true + this.loading.subAdminGroupsDetails = true + try { + const groups = await loadUserSubAdminGroups({ userId: this.user.id }) + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + this.selectedSubAdminGroups = this.selectedSubAdminGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup) + } catch (error) { + logger.error(t('settings', 'Failed to load sub admin groups with details'), { error }) + } + this.loading.subadmins = false + this.loading.subAdminGroupsDetails = false + }, + + async searchGroups(query, toggleLoading) { + if (query === '') { + return // Prevent unexpected search behaviour e.g. on option:created + } + if (this.promise) { + this.promise.cancel() + } + toggleLoading(true) + try { + this.promise = await searchGroups({ + search: query, + offset: 0, + limit: 25, + }) + const groups = await this.promise + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + } catch (error) { + logger.error(t('settings', 'Failed to search groups'), { error }) + } + this.promise = null + toggleLoading(false) + }, + async searchUserManager(query) { await this.$store.dispatch('searchUsers', { offset: 0, limit: 10, search: query }).then(response => { const users = response?.data ? this.filterManagers(Object.values(response?.data.ocs.data.users)) : [] @@ -574,11 +643,12 @@ export default { }) }, - async updateUserManager(manager) { - if (manager === null) { - this.currentManager = '' - } + async updateUserManager() { this.loading.manager = true + + // Store the current manager before making changes + const previousManager = this.user.manager + try { await this.$store.dispatch('setUserData', { userid: this.user.id, @@ -586,16 +656,20 @@ export default { value: this.currentManager ? this.currentManager.id : '', }) } catch (error) { - // TRANSLATORS This string describes a manager in the context of an organization - showError(t('setting', 'Failed to update user manager')) - console.error(error) + // TRANSLATORS This string describes a line manager in the context of an organization + showError(t('settings', 'Failed to update line manager')) + logger.error('Failed to update manager:', { error }) + + // Revert to the previous manager in the UI on error + this.currentManager = previousManager } finally { this.loading.manager = false } }, - deleteUser() { + async deleteUser() { const userid = this.user.id + await confirmPassword() OC.dialogs.confirmDestructive( t('settings', 'Fully delete {userid}\'s account including all their personal files, app data, etc.', { userid }), t('settings', 'Account deletion'), @@ -637,68 +711,70 @@ export default { /** * Set user displayName - * - * @param {string} displayName The display name */ - updateDisplayName() { + async updateDisplayName() { this.loading.displayName = true - this.$store.dispatch('setUserData', { - userid: this.user.id, - key: 'displayname', - value: this.editedDisplayName, - }).then(() => { - this.loading.displayName = false + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'displayname', + value: this.editedDisplayName, + }) + if (this.editedDisplayName === this.user.displayname) { - showSuccess(t('setting', 'Display name was successfully changed')) + showSuccess(t('settings', 'Display name was successfully changed')) } - }) + } finally { + this.loading.displayName = false + } }, /** * Set user password - * - * @param {string} password The email address */ - updatePassword() { + async updatePassword() { this.loading.password = true if (this.editedPassword.length === 0) { - showError(t('setting', "Password can't be empty")) + showError(t('settings', "Password can't be empty")) this.loading.password = false } else { - this.$store.dispatch('setUserData', { - userid: this.user.id, - key: 'password', - value: this.editedPassword, - }).then(() => { - this.loading.password = false + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'password', + value: this.editedPassword, + }) this.editedPassword = '' - showSuccess(t('setting', 'Password was successfully changed')) - }) + showSuccess(t('settings', 'Password was successfully changed')) + } finally { + this.loading.password = false + } } }, /** * Set user mailAddress - * - * @param {string} mailAddress The email address */ - updateEmail() { + async updateEmail() { this.loading.mailAddress = true if (this.editedMail === '') { - showError(t('setting', "Email can't be empty")) + showError(t('settings', "Email can't be empty")) this.loading.mailAddress = false this.editedMail = this.user.email } else { - this.$store.dispatch('setUserData', { - userid: this.user.id, - key: 'email', - value: this.editedMail, - }).then(() => { - this.loading.mailAddress = false + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'email', + value: this.editedMail, + }) + if (this.editedMail === this.user.email) { - showSuccess(t('setting', 'Email was successfully changed')) + showSuccess(t('settings', 'Email was successfully changed')) } - }) + } finally { + this.loading.mailAddress = false + } } }, @@ -708,17 +784,16 @@ export default { * @param {string} gid Group id */ async createGroup({ name: gid }) { - this.loading = { groups: true, subadmins: true } + this.loading.groups = true try { await this.$store.dispatch('addGroup', gid) const userid = this.user.id await this.$store.dispatch('addUserGroup', { userid, gid }) + this.userGroups.push({ id: gid, name: gid }) } catch (error) { - console.error(error) - } finally { - this.loading = { groups: false, subadmins: false } + logger.error(t('settings', 'Failed to create group'), { error }) } - return this.$store.getters.getGroups[this.groups.length] + this.loading.groups = false }, /** @@ -732,19 +807,19 @@ export default { // Ignore return } - this.loading.groups = true const userid = this.user.id const gid = group.id if (group.canAdd === false) { - return false + return } + this.loading.groups = true try { await this.$store.dispatch('addUserGroup', { userid, gid }) + this.userGroups.push(group) } catch (error) { console.error(error) - } finally { - this.loading.groups = false } + this.loading.groups = false }, /** @@ -764,6 +839,7 @@ export default { userid, gid, }) + this.userGroups = this.userGroups.filter(group => group.id !== gid) this.loading.groups = false // remove user from current list if current list is the removed group if (this.$route.params.selectedGroup === gid) { @@ -788,10 +864,11 @@ export default { userid, gid, }) - this.loading.subadmins = false + this.userSubAdminGroups.push(group) } catch (error) { console.error(error) } + this.loading.subadmins = false }, /** @@ -809,6 +886,7 @@ export default { userid, gid, }) + this.userSubAdminGroups = this.userSubAdminGroups.filter(group => group.id !== gid) } catch (error) { console.error(error) } finally { @@ -898,7 +976,7 @@ export default { sendWelcomeMail() { this.loading.all = true this.$store.dispatch('sendWelcomeMail', this.user.id) - .then(() => showSuccess(t('setting', 'Welcome mail sent!'), { timeout: 2000 })) + .then(() => showSuccess(t('settings', 'Welcome mail sent!'), { timeout: 2000 })) .finally(() => { this.loading.all = false }) @@ -909,6 +987,8 @@ export default { if (this.editing) { await this.$nextTick() this.$refs.displayNameField?.$refs?.inputField?.$refs?.input?.focus() + this.loadGroupsDetails() + this.loadSubAdminGroupsDetails() } if (this.editedDisplayName !== this.user.displayname) { this.editedDisplayName = this.user.displayname @@ -921,10 +1001,10 @@ export default { </script> <style lang="scss" scoped> -@import './shared/styles.scss'; +@use './shared/styles'; .user-list__row { - @include row; + @include styles.row; &:hover { background-color: var(--color-background-hover); @@ -941,7 +1021,7 @@ export default { } .row { - @include cell; + @include styles.cell; &__cell { border-bottom: 1px solid var(--color-border); |