aboutsummaryrefslogtreecommitdiffstats
path: root/apps/settings/src/components/Users/UserRow.vue
diff options
context:
space:
mode:
authorChristopher Ng <chrng8@gmail.com>2023-06-16 18:03:35 -0700
committerPytal <24800714+Pytal@users.noreply.github.com>2023-06-21 11:12:40 -0700
commit84ff000767aaf52a0a176cb28bde373cc7f24ca1 (patch)
tree2208411a5285269aaf6fc40e7fedf3bf17c4532a /apps/settings/src/components/Users/UserRow.vue
parent3a557a88ce01a9c3694d1a72ca42bd89975aa66f (diff)
downloadnextcloud-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.vue758
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>