diff options
author | Christopher Ng <chrng8@gmail.com> | 2023-06-16 18:03:35 -0700 |
---|---|---|
committer | Pytal <24800714+Pytal@users.noreply.github.com> | 2023-06-21 11:12:40 -0700 |
commit | 84ff000767aaf52a0a176cb28bde373cc7f24ca1 (patch) | |
tree | 2208411a5285269aaf6fc40e7fedf3bf17c4532a /apps/settings/src/components/Users/UserRow.vue | |
parent | 3a557a88ce01a9c3694d1a72ca42bd89975aa66f (diff) | |
download | nextcloud-server-84ff000767aaf52a0a176cb28bde373cc7f24ca1.tar.gz nextcloud-server-84ff000767aaf52a0a176cb28bde373cc7f24ca1.zip |
enh(a11y): New user modal
Signed-off-by: Christopher Ng <chrng8@gmail.com>
Diffstat (limited to 'apps/settings/src/components/Users/UserRow.vue')
-rw-r--r-- | apps/settings/src/components/Users/UserRow.vue | 758 |
1 files changed, 758 insertions, 0 deletions
diff --git a/apps/settings/src/components/Users/UserRow.vue b/apps/settings/src/components/Users/UserRow.vue new file mode 100644 index 00000000000..60dfbd03934 --- /dev/null +++ b/apps/settings/src/components/Users/UserRow.vue @@ -0,0 +1,758 @@ +<!-- + - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> + - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - @author Gary Kim <gary@garykim.dev> + - + - @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> + <!-- Obfuscated user: Logged in user does not have permissions to see all of the data --> + <div v-if="Object.keys(user).length ===1" :data-id="user.id" class="row"> + <div :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}" + class="avatar"> + <img v-if="!loading.delete && !loading.disable && !loading.wipe" + :src="generateAvatar(user.id, isDarkTheme)" + alt="" + height="32" + width="32"> + </div> + <div class="name"> + {{ user.id }} + </div> + <div class="obfuscated"> + {{ t('settings','You do not have permissions to see the details of this user') }} + </div> + </div> + + <!-- User full data --> + <UserRowSimple v-else-if="!editing" + :editing.sync="editing" + :feedback-message="feedbackMessage" + :groups="groups" + :languages="languages" + :loading="loading" + :opened-menu.sync="openedMenu" + :settings="settings" + :show-config="showConfig" + :sub-admins-groups="subAdminsGroups" + :user-actions="userActions" + :user="user" + :is-dark-theme="isDarkTheme" + :class="{'row--menu-opened': openedMenu}" /> + <div v-else + :class="{ + 'disabled': loading.delete || loading.disable, + 'row--menu-opened': openedMenu + }" + :data-id="user.id" + class="row row--editable"> + <div :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}" + class="avatar"> + <img v-if="!loading.delete && !loading.disable && !loading.wipe" + :src="generateAvatar(user.id, isDarkTheme)" + alt="" + height="32" + width="32"> + </div> + <!-- dirty hack to ellipsis on two lines --> + <div v-if="user.backendCapabilities.setDisplayName" class="displayName"> + <form :class="{'icon-loading-small': loading.displayName}" + class="displayName" + @submit.prevent="updateDisplayName"> + <label class="hidden-visually" :for="'displayName'+user.id+rand">{{ t('settings', 'Edit display name') }}</label> + <input :id="'displayName'+user.id+rand" + ref="displayName" + :disabled="loading.displayName||loading.all" + :value="user.displayname" + autocapitalize="off" + autocomplete="off" + autocorrect="off" + spellcheck="false" + type="text"> + <input class="icon-confirm" + type="submit" + value=""> + </form> + </div> + <div v-else class="name"> + {{ user.id }} + <div class="displayName subtitle"> + <div :title="user.displayname.length > 20 ? user.displayname : ''" class="cellText"> + {{ user.displayname }} + </div> + </div> + </div> + <form v-if="settings.canChangePassword && user.backendCapabilities.setPassword" + :class="{'icon-loading-small': loading.password}" + class="password" + @submit.prevent="updatePassword"> + <label class="hidden-visually" :for="'password'+user.id+rand">{{ t('settings', 'Add new password') }}</label> + <input :id="'password'+user.id+rand" + ref="password" + :disabled="loading.password || loading.all" + :minlength="minPasswordLength" + maxlength="469" + :placeholder="t('settings', 'Add new password')" + autocapitalize="off" + autocomplete="new-password" + autocorrect="off" + required + spellcheck="false" + type="password" + value=""> + <input class="icon-confirm" type="submit" value=""> + </form> + <div v-else /> + <form :class="{'icon-loading-small': loading.mailAddress}" + class="mailAddress" + @submit.prevent="updateEmail"> + <label class="hidden-visually" :for="'mailAddress'+user.id+rand">{{ t('settings', 'Add new email address') }}</label> + <input :id="'mailAddress'+user.id+rand" + ref="mailAddress" + :disabled="loading.mailAddress||loading.all" + :placeholder="t('settings', 'Add new email address')" + :value="user.email" + autocapitalize="off" + autocomplete="new-password" + autocorrect="off" + spellcheck="false" + type="email"> + <input class="icon-confirm" type="submit" value=""> + </form> + <div :class="{'icon-loading-small': loading.groups}" class="groups"> + <label class="hidden-visually" :for="'groups'+user.id+rand">{{ t('settings', 'Add user to group') }}</label> + <NcMultiselect :id="'groups'+user.id+rand" + :close-on-select="false" + :disabled="loading.groups||loading.all" + :limit="2" + :multiple="true" + :options="availableGroups" + :placeholder="t('settings', 'Add user to group')" + :tag-width="60" + :taggable="settings.isAdmin" + :value="userGroups" + class="multiselect-vue" + label="name" + tag-placeholder="create" + track-by="id" + @remove="removeUserGroup" + @select="addUserGroup" + @tag="createGroup"> + <span slot="noResult">{{ t('settings', 'No results') }}</span> + </NcMultiselect> + </div> + <div v-if="subAdminsGroups.length>0 && settings.isAdmin" + :class="{'icon-loading-small': loading.subadmins}" + class="subadmins"> + <label class="hidden-visually" :for="'subadmins'+user.id+rand">{{ t('settings', 'Set user as admin for') }}</label> + <NcMultiselect :id="'subadmins'+user.id+rand" + :close-on-select="false" + :disabled="loading.subadmins||loading.all" + :limit="2" + :multiple="true" + :options="subAdminsGroups" + :placeholder="t('settings', 'Set user as admin for')" + :tag-width="60" + :value="userSubAdminsGroups" + class="multiselect-vue" + label="name" + track-by="id" + @remove="removeUserSubAdmin" + @select="addUserSubAdmin"> + <span slot="noResult">{{ t('settings', 'No results') }}</span> + </NcMultiselect> + </div> + <div :title="usedSpace" + :class="{'icon-loading-small': loading.quota}" + class="quota"> + <label class="hidden-visually" :for="'quota'+user.id+rand">{{ t('settings', 'Select user quota') }}</label> + <NcMultiselect :id="'quota'+user.id+rand" + :allow-empty="false" + :disabled="loading.quota||loading.all" + :options="quotaOptions" + :placeholder="t('settings', 'Select user quota')" + :taggable="true" + :value="userQuota" + class="multiselect-vue" + label="label" + tag-placeholder="create" + track-by="id" + @input="setUserQuota" + @tag="validateQuota" /> + </div> + <div v-if="showConfig.showLanguages" + :class="{'icon-loading-small': loading.languages}" + class="languages"> + <label class="hidden-visually" :for="'language'+user.id+rand">{{ t('settings', 'Set the language') }}</label> + <NcMultiselect :id="'language'+user.id+rand" + :allow-empty="false" + :disabled="loading.languages||loading.all" + :options="languages" + :placeholder="t('settings', 'No language set')" + :value="userLanguage" + class="multiselect-vue" + group-label="label" + group-values="languages" + label="name" + track-by="code" + @input="setUserLanguage" /> + </div> + <div :class="{'icon-loading-small': loading.manager}" class="managers"> + <NcMultiselect ref="manager" + v-model="currentManager" + :close-on-select="true" + :user-select="true" + :options="possibleManagers" + :placeholder="t('settings', 'Select manager')" + class="multiselect-vue" + label="displayname" + track-by="id" + @search-change="searchUserManager" + @remove="updateUserManager" + @select="updateUserManager"> + <span slot="noResult">{{ t('settings', 'No results') }}</span> + </NcMultiselect> + </div> + + <!-- don't show this on edit mode --> + <div v-if="showConfig.showStoragePath || showConfig.showUserBackend" + class="storageLocation" /> + <div v-if="showConfig.showLastLogin" /> + + <div class="userActions"> + <div v-if="!loading.all" + class="toggleUserActions"> + <NcActions> + <NcActionButton icon="icon-checkmark" + :title="t('settings', 'Done')" + :aria-label="t('settings', 'Done')" + @click="editing = false" /> + </NcActions> + <div v-click-outside="hideMenu" class="userPopoverMenuWrapper"> + <button class="icon-more" + :aria-expanded="openedMenu" + :aria-label="t('settings', 'Toggle user actions menu')" + @click.prevent="toggleMenu" /> + <div :class="{ 'open': openedMenu }" class="popovermenu"> + <NcPopoverMenu :menu="userActions" /> + </div> + </div> + </div> + <div :style="{opacity: feedbackMessage !== '' ? 1 : 0}" + class="feedback"> + <div class="icon-checkmark" /> + {{ feedbackMessage }} + </div> + </div> + </div> +</template> + +<script> +import ClickOutside from 'vue-click-outside' + +import NcPopoverMenu from '@nextcloud/vue/dist/Components/NcPopoverMenu.js' +import NcMultiselect from '@nextcloud/vue/dist/Components/NcMultiselect.js' +import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' +import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' +import UserRowSimple from './UserRowSimple.vue' +import UserRowMixin from '../../mixins/UserRowMixin.js' + +export default { + name: 'UserRow', + components: { + UserRowSimple, + NcPopoverMenu, + NcActions, + NcActionButton, + NcMultiselect, + }, + directives: { + ClickOutside, + }, + mixins: [UserRowMixin], + props: { + users: { + type: Array, + required: true, + }, + user: { + type: Object, + required: true, + }, + settings: { + type: Object, + default: () => ({}), + }, + groups: { + type: Array, + default: () => [], + }, + subAdminsGroups: { + type: Array, + default: () => [], + }, + quotaOptions: { + type: Array, + default: () => [], + }, + showConfig: { + type: Object, + default: () => ({}), + }, + languages: { + type: Array, + required: true, + }, + externalActions: { + type: Array, + default: () => [], + }, + isDarkTheme: { + type: Boolean, + required: true, + }, + }, + data() { + return { + rand: parseInt(Math.random() * 1000), + openedMenu: false, + feedbackMessage: '', + possibleManagers: [], + currentManager: '', + editing: false, + loading: { + all: false, + displayName: false, + password: false, + mailAddress: false, + groups: false, + subadmins: false, + quota: false, + delete: false, + disable: false, + languages: false, + wipe: false, + manager: false, + }, + } + }, + computed: { + + /* USER POPOVERMENU ACTIONS */ + userActions() { + const actions = [ + { + icon: 'icon-delete', + text: t('settings', 'Delete user'), + action: this.deleteUser, + }, + { + icon: 'icon-delete', + text: t('settings', 'Wipe all devices'), + action: this.wipeUserDevices, + }, + { + icon: this.user.enabled ? 'icon-close' : 'icon-add', + text: this.user.enabled ? t('settings', 'Disable user') : t('settings', 'Enable user'), + action: this.enableDisableUser, + }, + ] + if (this.user.email !== null && this.user.email !== '') { + actions.push({ + icon: 'icon-mail', + text: t('settings', 'Resend welcome email'), + action: this.sendWelcomeMail, + }) + } + return actions.concat(this.externalActions) + }, + }, + async beforeMount() { + await this.searchUserManager() + if (this.user.manager) { + await this.initManager(this.user.manager) + } + }, + + methods: { + /* MENU HANDLING */ + toggleMenu() { + this.openedMenu = !this.openedMenu + }, + hideMenu() { + this.openedMenu = false + }, + + wipeUserDevices() { + const userid = this.user.id + 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'), + { + type: OC.dialogs.YES_NO_BUTTONS, + confirm: t('settings', 'Wipe {userid}\'s devices', { userid }), + confirmClasses: 'error', + cancel: t('settings', 'Cancel'), + }, + (result) => { + if (result) { + this.loading.wipe = true + this.loading.all = true + this.$store.dispatch('wipeUserDevices', userid) + .then(() => { + this.loading.wipe = false + this.loading.all = false + }) + } + }, + true + ) + }, + + filterManagers(managers) { + return managers.filter((manager) => manager.id !== this.user.id) + }, + async initManager(userId) { + await this.$store.dispatch('getUser', userId).then(response => { + this.currentManager = response?.data.ocs.data + }) + }, + 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)) : [] + if (users.length > 0) { + this.possibleManagers = users + } + }) + }, + + updateUserManager(manager) { + this.loading.manager = true + this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'manager', + value: this.currentManager ? this.currentManager.id : '', + }).then(() => { + this.loading.manager = false + }) + }, + + deleteUser() { + const userid = this.user.id + OC.dialogs.confirmDestructive( + t('settings', 'Fully delete {userid}\'s account including all their personal files, app data, etc.', { userid }), + t('settings', 'Account deletion'), + { + type: OC.dialogs.YES_NO_BUTTONS, + confirm: t('settings', 'Delete {userid}\'s account', { userid }), + confirmClasses: 'error', + cancel: t('settings', 'Cancel'), + }, + (result) => { + if (result) { + this.loading.delete = true + this.loading.all = true + return this.$store.dispatch('deleteUser', userid) + .then(() => { + this.loading.delete = false + this.loading.all = false + }) + } + }, + true + ) + }, + + enableDisableUser() { + this.loading.delete = true + this.loading.all = true + const userid = this.user.id + const enabled = !this.user.enabled + return this.$store.dispatch('enableDisableUser', { + userid, + enabled, + }) + .then(() => { + this.loading.delete = false + this.loading.all = false + }) + }, + + /** + * Set user displayName + * + * @param {string} displayName The display name + */ + updateDisplayName() { + const displayName = this.$refs.displayName.value + this.loading.displayName = true + this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'displayname', + value: displayName, + }).then(() => { + this.loading.displayName = false + this.$refs.displayName.value = displayName + }) + }, + + /** + * Set user password + * + * @param {string} password The email address + */ + updatePassword() { + const password = this.$refs.password.value + this.loading.password = true + this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'password', + value: password, + }).then(() => { + this.loading.password = false + this.$refs.password.value = '' // empty & show placeholder + }) + }, + + /** + * Set user mailAddress + * + * @param {string} mailAddress The email address + */ + updateEmail() { + const mailAddress = this.$refs.mailAddress.value + this.loading.mailAddress = true + this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'email', + value: mailAddress, + }).then(() => { + this.loading.mailAddress = false + this.$refs.mailAddress.value = mailAddress + }) + }, + + /** + * Create a new group and add user to it + * + * @param {string} gid Group id + */ + async createGroup(gid) { + this.loading = { groups: true, subadmins: true } + try { + await this.$store.dispatch('addGroup', gid) + const userid = this.user.id + await this.$store.dispatch('addUserGroup', { userid, gid }) + } catch (error) { + console.error(error) + } finally { + this.loading = { groups: false, subadmins: false } + } + return this.$store.getters.getGroups[this.groups.length] + }, + + /** + * Add user to group + * + * @param {object} group Group object + */ + async addUserGroup(group) { + if (group.canAdd === false) { + return false + } + this.loading.groups = true + const userid = this.user.id + const gid = group.id + try { + await this.$store.dispatch('addUserGroup', { userid, gid }) + } catch (error) { + console.error(error) + } finally { + this.loading.groups = false + } + }, + + /** + * Remove user from group + * + * @param {object} group Group object + */ + async removeUserGroup(group) { + if (group.canRemove === false) { + return false + } + + this.loading.groups = true + const userid = this.user.id + const gid = group.id + + try { + await this.$store.dispatch('removeUserGroup', { + userid, + gid, + }) + this.loading.groups = false + // remove user from current list if current list is the removed group + if (this.$route.params.selectedGroup === gid) { + this.$store.commit('deleteUser', userid) + } + } catch { + this.loading.groups = false + } + }, + + /** + * Add user to group + * + * @param {object} group Group object + */ + async addUserSubAdmin(group) { + this.loading.subadmins = true + const userid = this.user.id + const gid = group.id + + try { + await this.$store.dispatch('addUserSubAdmin', { + userid, + gid, + }) + this.loading.subadmins = false + } catch (error) { + console.error(error) + } + }, + + /** + * Remove user from group + * + * @param {object} group Group object + */ + async removeUserSubAdmin(group) { + this.loading.subadmins = true + const userid = this.user.id + const gid = group.id + + try { + await this.$store.dispatch('removeUserSubAdmin', { + userid, + gid, + }) + } catch (error) { + console.error(error) + } finally { + this.loading.subadmins = false + } + }, + + /** + * Dispatch quota set request + * + * @param {string | object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'} + * @return {string} + */ + async setUserQuota(quota = 'none') { + this.loading.quota = true + // ensure we only send the preset id + quota = quota.id ? quota.id : quota + + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'quota', + value: quota, + }) + } catch (error) { + console.error(error) + } finally { + this.loading.quota = false + } + return quota + }, + + /** + * Validate quota string to make sure it's a valid human file size + * + * @param {string} quota Quota in readable format '5 GB' + * @return {Promise|boolean} + */ + validateQuota(quota) { + // only used for new presets sent through @Tag + const validQuota = OC.Util.computerFileSize(quota) + if (validQuota !== null && validQuota >= 0) { + // unify format output + return this.setUserQuota(OC.Util.humanFileSize(OC.Util.computerFileSize(quota))) + } + // if no valid do not change + return false + }, + + /** + * Dispatch language set request + * + * @param {object} lang language object {code:'en', name:'English'} + * @return {object} + */ + async setUserLanguage(lang) { + this.loading.languages = true + // ensure we only send the preset id + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'language', + value: lang.code, + }) + } catch (error) { + console.error(error) + } finally { + this.loading.languages = false + } + return lang + }, + + /** + * Dispatch new welcome mail request + */ + sendWelcomeMail() { + this.loading.all = true + this.$store.dispatch('sendWelcomeMail', this.user.id) + .then(success => { + if (success) { + // Show feedback to indicate the success + this.feedbackMessage = t('setting', 'Welcome mail sent!') + setTimeout(() => { + this.feedbackMessage = '' + }, 2000) + } + this.loading.all = false + }) + }, + + }, +} +</script> +<style scoped lang="scss"> + // Force menu to be above other rows + .row--menu-opened { + z-index: 1 !important; + } + .row::v-deep .multiselect__single { + z-index: auto !important; + } +</style> |