diff options
Diffstat (limited to 'apps/settings/src/components/Users')
-rw-r--r-- | apps/settings/src/components/Users/NewUserDialog.vue | 436 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserListFooter.vue | 112 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserListHeader.vue | 152 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserRow.vue | 1049 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserRowActions.vue | 119 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserSettingsDialog.vue | 337 | ||||
-rw-r--r-- | apps/settings/src/components/Users/VirtualList.vue | 184 | ||||
-rw-r--r-- | apps/settings/src/components/Users/shared/styles.scss | 110 |
8 files changed, 2499 insertions, 0 deletions
diff --git a/apps/settings/src/components/Users/NewUserDialog.vue b/apps/settings/src/components/Users/NewUserDialog.vue new file mode 100644 index 00000000000..ef401b565fa --- /dev/null +++ b/apps/settings/src/components/Users/NewUserDialog.vue @@ -0,0 +1,436 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcDialog class="dialog" + size="small" + :name="t('settings', 'New account')" + out-transition + v-on="$listeners"> + <form id="new-user-form" + class="dialog__form" + data-test="form" + :disabled="loading.all" + @submit.prevent="createUser"> + <NcTextField ref="username" + class="dialog__item" + data-test="username" + :value.sync="newUser.id" + :disabled="settings.newUserGenerateUserID" + :label="usernameLabel" + autocapitalize="none" + autocomplete="off" + spellcheck="false" + pattern="[a-zA-Z0-9 _\.@\-']+" + required /> + <NcTextField class="dialog__item" + data-test="displayName" + :value.sync="newUser.displayName" + :label="t('settings', 'Display name')" + autocapitalize="none" + autocomplete="off" + spellcheck="false" /> + <span v-if="!settings.newUserRequireEmail" + id="password-email-hint" + class="dialog__hint"> + {{ t('settings', 'Either password or email is required') }} + </span> + <NcPasswordField ref="password" + class="dialog__item" + data-test="password" + :value.sync="newUser.password" + :minlength="minPasswordLength" + :maxlength="469" + aria-describedby="password-email-hint" + :label="newUser.mailAddress === '' ? t('settings', 'Password (required)') : t('settings', 'Password')" + autocapitalize="none" + autocomplete="new-password" + spellcheck="false" + :required="newUser.mailAddress === ''" /> + <NcTextField class="dialog__item" + data-test="email" + type="email" + :value.sync="newUser.mailAddress" + aria-describedby="password-email-hint" + :label="newUser.password === '' || settings.newUserRequireEmail ? t('settings', 'Email (required)') : t('settings', 'Email')" + autocapitalize="none" + autocomplete="off" + spellcheck="false" + :required="newUser.password === '' || settings.newUserRequireEmail" /> + <div class="dialog__item"> + <NcSelect class="dialog__select" + data-test="groups" + :input-label="!settings.isAdmin && !settings.isDelegatedAdmin ? t('settings', 'Member of the following groups (required)') : t('settings', 'Member of the following groups')" + :placeholder="t('settings', 'Set account groups')" + :disabled="loading.groups || loading.all" + :options="availableGroups" + :value="newUser.groups" + label="name" + :close-on-select="false" + :multiple="true" + :taggable="settings.isAdmin || settings.isDelegatedAdmin" + :required="!settings.isAdmin && !settings.isDelegatedAdmin" + :create-option="(value) => ({ id: value, name: value, isCreating: true })" + @search="searchGroups" + @option:created="createGroup" + @option:selected="options => addGroup(options.at(-1))" /> + <!-- If user is not admin, they are a subadmin. + Subadmins can't create users outside their groups + Therefore, empty select is forbidden --> + </div> + <div class="dialog__item"> + <NcSelect v-model="newUser.subAdminsGroups" + class="dialog__select" + :input-label="t('settings', 'Admin of the following groups')" + :placeholder="t('settings', 'Set account as admin for …')" + :disabled="loading.groups || loading.all" + :options="availableGroups" + :close-on-select="false" + :multiple="true" + label="name" + @search="searchGroups" /> + </div> + <div class="dialog__item"> + <NcSelect v-model="newUser.quota" + class="dialog__select" + :input-label="t('settings', 'Quota')" + :placeholder="t('settings', 'Set account quota')" + :options="quotaOptions" + :clearable="false" + :taggable="true" + :create-option="validateQuota" /> + </div> + <div v-if="showConfig.showLanguages" + class="dialog__item"> + <NcSelect v-model="newUser.language" + class="dialog__select" + :input-label="t('settings', 'Language')" + :placeholder="t('settings', 'Set default language')" + :clearable="false" + :selectable="option => !option.languages" + :filter-by="languageFilterBy" + :options="languages" + label="name" /> + </div> + <div :class="['dialog__item dialog__managers', { 'icon-loading-small': loading.manager }]"> + <NcSelect v-model="newUser.manager" + class="dialog__select" + :input-label="managerInputLabel" + :placeholder="managerLabel" + :options="possibleManagers" + :user-select="true" + label="displayname" + @search="searchUserManager" /> + </div> + </form> + + <template #actions> + <NcButton class="dialog__submit" + data-test="submit" + form="new-user-form" + type="primary" + native-type="submit"> + {{ t('settings', 'Add new account') }} + </NcButton> + </template> + </NcDialog> +</template> + +<script> +import { formatFileSize, parseFileSize } from '@nextcloud/files' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import { searchGroups } from '../../service/groups.ts' +import logger from '../../logger.ts' + +export default { + name: 'NewUserDialog', + + components: { + NcButton, + NcDialog, + NcPasswordField, + NcSelect, + NcTextField, + }, + + props: { + loading: { + type: Object, + required: true, + }, + + newUser: { + type: Object, + required: true, + }, + + quotaOptions: { + type: Array, + required: true, + }, + }, + + data() { + return { + possibleManagers: [], + // TRANSLATORS This string describes a manager in the context of an organization + managerInputLabel: t('settings', 'Manager'), + // TRANSLATORS This string describes a manager in the context of an organization + managerLabel: t('settings', 'Set line manager'), + // Cancelable promise for search groups request + promise: null, + } + }, + + computed: { + showConfig() { + return this.$store.getters.getShowConfig + }, + + settings() { + return this.$store.getters.getServerData + }, + + usernameLabel() { + if (this.settings.newUserGenerateUserID) { + return t('settings', 'Account name will be autogenerated') + } + return t('settings', 'Account name (required)') + }, + + minPasswordLength() { + return this.$store.getters.getPasswordPolicyMinLength + }, + + 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') + }, + + languages() { + return [ + { + name: t('settings', 'Common languages'), + languages: this.settings.languages.commonLanguages, + }, + ...this.settings.languages.commonLanguages, + { + name: t('settings', 'Other languages'), + languages: this.settings.languages.otherLanguages, + }, + ...this.settings.languages.otherLanguages, + ] + }, + }, + + async beforeMount() { + await this.searchUserManager() + }, + + mounted() { + this.$refs.username?.focus?.() + }, + + methods: { + async createUser() { + this.loading.all = true + try { + await 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, + manager: this.newUser.manager.id, + }) + + this.$emit('reset') + this.$refs.username?.focus?.() + this.$emit('closing') + } 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.username?.focus?.() + } else if (statuscode === 107) { + // wrong password + this.$refs.password?.focus?.() + } + } + } + }, + + async searchGroups(query, toggleLoading) { + if (!this.settings.isAdmin && !this.settings.isDelegatedAdmin) { + // managers cannot search for groups + return + } + + if (this.promise) { + this.promise.cancel() + } + toggleLoading(true) + try { + this.promise = 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) + }, + + /** + * Create a new group + * + * @param {any} group Group + * @param {string} group.name Group id + */ + async createGroup({ name: gid }) { + this.loading.groups = true + try { + await this.$store.dispatch('addGroup', gid) + this.newUser.groups.push({ id: gid, name: gid }) + } catch (error) { + logger.error(t('settings', 'Failed to create group'), { error }) + } + this.loading.groups = false + }, + + /** + * Add user to group + * + * @param {object} group Group object + */ + async addGroup(group) { + if (group.isCreating) { + return + } + if (group.canAdd === false) { + return + } + this.newUser.groups.push(group) + }, + + /** + * Validate quota string to make sure it's a valid human file size + * + * @param {string} quota Quota in readable format '5 GB' + * @return {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 = formatFileSize(parseFileSize(quota, true)) + this.newUser.quota = { id: quota, label: quota } + return this.newUser.quota + } + // Default is unlimited + this.newUser.quota = this.quotaOptions[0] + return this.quotaOptions[0] + }, + + languageFilterBy(option, label, search) { + // Show group header of the language + if (option.languages) { + return option.languages.some( + ({ name }) => name.toLocaleLowerCase().includes(search.toLocaleLowerCase()), + ) + } + + return (label || '').toLocaleLowerCase().includes(search.toLocaleLowerCase()) + }, + + async searchUserManager(query) { + await this.$store.dispatch( + 'searchUsers', + { + offset: 0, + limit: 10, + search: query, + }, + ).then(response => { + const users = response?.data ? Object.values(response?.data.ocs.data.users) : [] + if (users.length > 0) { + this.possibleManagers = users + } + }) + }, + }, +} +</script> + +<style lang="scss" scoped> +.dialog { + &__form { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 8px; + gap: 4px 0; + } + + &__item { + width: 100%; + + &:not(:focus):not(:active) { + border-color: var(--color-border-dark); + } + } + + &__hint { + color: var(--color-text-maxcontrast); + margin-top: 8px; + align-self: flex-start; + } + + &__label { + display: block; + padding: 4px 0; + } + + &__select { + width: 100%; + } + + &__managers { + margin-bottom: 12px; + } + + &__submit { + margin-top: 4px; + margin-bottom: 8px; + } + + :deep { + .dialog__actions { + margin: auto; + } + } +} +</style> diff --git a/apps/settings/src/components/Users/UserListFooter.vue b/apps/settings/src/components/Users/UserListFooter.vue new file mode 100644 index 00000000000..bf9aa43b6d3 --- /dev/null +++ b/apps/settings/src/components/Users/UserListFooter.vue @@ -0,0 +1,112 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <tr class="footer"> + <th scope="row"> + <!-- TRANSLATORS Label for a table footer which summarizes the columns of the table --> + <span class="hidden-visually">{{ t('settings', 'Total rows summary') }}</span> + </th> + <td class="footer__cell footer__cell--loading"> + <NcLoadingIcon v-if="loading" + :title="t('settings', 'Loading accounts …')" + :size="32" /> + </td> + <td class="footer__cell footer__cell--count footer__cell--multiline"> + <span aria-describedby="user-count-desc">{{ userCount }}</span> + <span id="user-count-desc" + class="hidden-visually"> + {{ t('settings', 'Scroll to load more rows') }} + </span> + </td> + </tr> +</template> + +<script lang="ts"> +import Vue from 'vue' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import { + translate as t, + translatePlural as n, +} from '@nextcloud/l10n' + +export default Vue.extend({ + name: 'UserListFooter', + + components: { + NcLoadingIcon, + }, + + props: { + loading: { + type: Boolean, + required: true, + }, + filteredUsers: { + type: Array, + required: true, + }, + }, + + computed: { + userCount(): string { + if (this.loading) { + return this.n( + 'settings', + '{userCount} account …', + '{userCount} accounts …', + this.filteredUsers.length, + { + userCount: this.filteredUsers.length, + }, + ) + } + return this.n( + 'settings', + '{userCount} account', + '{userCount} accounts', + this.filteredUsers.length, + { + userCount: this.filteredUsers.length, + }, + ) + }, + }, + + methods: { + t, + n, + }, +}) +</script> + +<style lang="scss" scoped> +@use './shared/styles'; + +.footer { + @include styles.row; + @include styles.cell; + + &__cell { + position: sticky; + color: var(--color-text-maxcontrast); + + &--loading { + inset-inline-start: 0; + min-width: var(--avatar-cell-width); + width: var(--avatar-cell-width); + align-items: center; + padding: 0; + } + + &--count { + inset-inline-start: var(--avatar-cell-width); + min-width: var(--cell-width); + width: var(--cell-width); + } + } +} +</style> diff --git a/apps/settings/src/components/Users/UserListHeader.vue b/apps/settings/src/components/Users/UserListHeader.vue new file mode 100644 index 00000000000..a85306d84d3 --- /dev/null +++ b/apps/settings/src/components/Users/UserListHeader.vue @@ -0,0 +1,152 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <tr class="header"> + <th class="header__cell header__cell--avatar" + data-cy-user-list-header-avatar + scope="col"> + <span class="hidden-visually"> + {{ t('settings', 'Avatar') }} + </span> + </th> + <th class="header__cell header__cell--displayname" + data-cy-user-list-header-displayname + scope="col"> + <strong> + {{ t('settings', 'Display name') }} + </strong> + </th> + <th class="header__cell header__cell--username" + data-cy-user-list-header-username + scope="col"> + <span> + {{ t('settings', 'Account name') }} + </span> + </th> + <th class="header__cell" + :class="{ 'header__cell--obfuscated': hasObfuscated }" + data-cy-user-list-header-password + scope="col"> + <span>{{ passwordLabel }}</span> + </th> + <th class="header__cell" + data-cy-user-list-header-email + scope="col"> + <span>{{ t('settings', 'Email') }}</span> + </th> + <th class="header__cell header__cell--large" + data-cy-user-list-header-groups + scope="col"> + <span>{{ t('settings', 'Groups') }}</span> + </th> + <th v-if="settings.isAdmin || settings.isDelegatedAdmin" + class="header__cell header__cell--large" + data-cy-user-list-header-subadmins + scope="col"> + <span>{{ t('settings', 'Group admin for') }}</span> + </th> + <th class="header__cell" + data-cy-user-list-header-quota + scope="col"> + <span>{{ t('settings', 'Quota') }}</span> + </th> + <th v-if="showConfig.showLanguages" + class="header__cell header__cell--large" + data-cy-user-list-header-languages + scope="col"> + <span>{{ t('settings', 'Language') }}</span> + </th> + <th v-if="showConfig.showUserBackend || showConfig.showStoragePath" + class="header__cell header__cell--large" + data-cy-user-list-header-storage-location + scope="col"> + <span v-if="showConfig.showUserBackend"> + {{ t('settings', 'Account backend') }} + </span> + <span v-if="showConfig.showStoragePath" + class="header__subtitle"> + {{ t('settings', 'Storage location') }} + </span> + </th> + <th v-if="showConfig.showFirstLogin" + class="header__cell" + data-cy-user-list-header-first-login + scope="col"> + <span>{{ t('settings', 'First login') }}</span> + </th> + <th v-if="showConfig.showLastLogin" + class="header__cell" + data-cy-user-list-header-last-login + scope="col"> + <span>{{ t('settings', 'Last login') }}</span> + </th> + <th class="header__cell header__cell--large header__cell--fill" + data-cy-user-list-header-manager + scope="col"> + <!-- TRANSLATORS This string describes a manager in the context of an organization --> + <span>{{ t('settings', 'Manager') }}</span> + </th> + <th class="header__cell header__cell--actions" + data-cy-user-list-header-actions + scope="col"> + <span class="hidden-visually"> + {{ t('settings', 'Account actions') }} + </span> + </th> + </tr> +</template> + +<script lang="ts"> +import Vue from 'vue' + +import { translate as t } from '@nextcloud/l10n' + +export default Vue.extend({ + name: 'UserListHeader', + + props: { + hasObfuscated: { + type: Boolean, + required: true, + }, + }, + + computed: { + showConfig() { + // @ts-expect-error: allow untyped $store + return this.$store.getters.getShowConfig + }, + + settings() { + // @ts-expect-error: allow untyped $store + return this.$store.getters.getServerData + }, + + passwordLabel(): string { + if (this.hasObfuscated) { + // TRANSLATORS This string is for a column header labelling either a password or a message that the current user has insufficient permissions + return t('settings', 'Password or insufficient permissions message') + } + return t('settings', 'Password') + }, + }, + + methods: { + t, + }, +}) +</script> + +<style lang="scss" scoped> +@use './shared/styles'; + +.header { + border-bottom: 1px solid var(--color-border); + + @include styles.row; + @include styles.cell; +} +</style> diff --git a/apps/settings/src/components/Users/UserRow.vue b/apps/settings/src/components/Users/UserRow.vue new file mode 100644 index 00000000000..43668725972 --- /dev/null +++ b/apps/settings/src/components/Users/UserRow.vue @@ -0,0 +1,1049 @@ +<!-- + - 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 account …')" + :size="32" /> + <NcAvatar v-else-if="visible" + disable-menu + :show-user-status="false" + :user="user.id" /> + </td> + + <td class="row__cell row__cell--displayname" data-cy-user-list-cell-displayname> + <template v-if="editing && user.backendCapabilities.setDisplayName"> + <NcTextField ref="displayNameField" + class="user-row-text-field" + data-cy-user-list-input-displayname + :data-loading="loading.displayName || undefined" + :trailing-button-label="t('settings', 'Submit')" + :class="{ 'icon-loading-small': loading.displayName }" + :show-trailing-button="true" + :disabled="loading.displayName || isLoadingField" + :label="t('settings', 'Change display name')" + trailing-button-icon="arrowRight" + :value.sync="editedDisplayName" + autocapitalize="off" + autocomplete="off" + spellcheck="false" + @trailing-button-click="updateDisplayName" /> + </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 + class="row__cell" + :class="{ 'row__cell--obfuscated': hasObfuscated }"> + <template v-if="editing && settings.canChangePassword && user.backendCapabilities.setPassword"> + <NcTextField class="user-row-text-field" + data-cy-user-list-input-password + :data-loading="loading.password || undefined" + :trailing-button-label="t('settings', 'Submit')" + :class="{'icon-loading-small': loading.password}" + :show-trailing-button="true" + :disabled="loading.password || isLoadingField" + :minlength="minPasswordLength" + maxlength="469" + :label="t('settings', 'Set new password')" + trailing-button-icon="arrowRight" + :value.sync="editedPassword" + autocapitalize="off" + autocomplete="new-password" + required + spellcheck="false" + type="password" + @trailing-button-click="updatePassword" /> + </template> + <span v-else-if="isObfuscated"> + {{ t('settings', 'You do not have permissions to see the details of this account') }} + </span> + </td> + + <td class="row__cell" data-cy-user-list-cell-email> + <template v-if="editing"> + <NcTextField class="user-row-text-field" + :class="{'icon-loading-small': loading.mailAddress}" + data-cy-user-list-input-email + :data-loading="loading.mailAddress || undefined" + :show-trailing-button="true" + :trailing-button-label="t('settings', 'Submit')" + :label="t('settings', 'Set new email address')" + :disabled="loading.mailAddress || isLoadingField" + trailing-button-icon="arrowRight" + :value.sync="editedMail" + autocapitalize="off" + autocomplete="email" + spellcheck="false" + type="email" + @trailing-button-click="updateEmail" /> + </template> + <span v-else-if="!isObfuscated" + :title="user.email?.length > 20 ? user.email : null"> + {{ user.email }} + </span> + </td> + + <td class="row__cell row__cell--large row__cell--multiline" data-cy-user-list-cell-groups> + <template v-if="editing"> + <label class="hidden-visually" + :for="'groups' + uniqueId"> + {{ 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 || loading.groupsDetails" + :loading="loading.groups" + :multiple="true" + :append-to-body="false" + :options="availableGroups" + :placeholder="t('settings', 'Add account to group')" + :taggable="settings.isAdmin || settings.isDelegatedAdmin" + :value="userGroups" + label="name" + :no-wrap="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" /> + </template> + <span v-else-if="!isObfuscated" + :title="userGroupsLabels?.length > 40 ? userGroupsLabels : null"> + {{ userGroupsLabels }} + </span> + </td> + + <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 || settings.isDelegatedAdmin)"> + <label class="hidden-visually" + :for="'subadmins' + uniqueId"> + {{ 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 || loading.subAdminGroupsDetails" + :loading="loading.subadmins" + label="name" + :append-to-body="false" + :multiple="true" + :no-wrap="true" + :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="userSubAdminGroupsLabels?.length > 40 ? userSubAdminGroupsLabels : null"> + {{ userSubAdminGroupsLabels }} + </span> + </td> + + <td class="row__cell" data-cy-user-list-cell-quota> + <template v-if="editing"> + <label class="hidden-visually" + :for="'quota' + uniqueId"> + {{ t('settings', 'Select account quota') }} + </label> + <NcSelect v-model="editedUserQuota" + :close-on-select="true" + :create-option="validateQuota" + data-cy-user-list-input-quota + :data-loading="loading.quota || undefined" + :disabled="isLoadingField" + :loading="loading.quota" + :append-to-body="false" + :clearable="false" + :input-id="'quota' + uniqueId" + :options="quotaOptions" + :placeholder="t('settings', 'Select account quota')" + :taggable="true" + @option:selected="setUserQuota" /> + </template> + <template v-else-if="!isObfuscated"> + <span :id="'quota-progress' + uniqueId">{{ userQuota }} ({{ usedSpace }})</span> + <NcProgressBar :aria-labelledby="'quota-progress' + uniqueId" + class="row__progress" + :class="{ + 'row__progress--warn': usedQuota > 80, + }" + :value="usedQuota" /> + </template> + </td> + + <td v-if="showConfig.showLanguages" + class="row__cell row__cell--large" + data-cy-user-list-cell-language> + <template v-if="editing"> + <label class="hidden-visually" + :for="'language' + uniqueId"> + {{ t('settings', 'Set the language') }} + </label> + <NcSelect :id="'language' + uniqueId" + data-cy-user-list-input-language + :data-loading="loading.languages || undefined" + :allow-empty="false" + :disabled="isLoadingField" + :loading="loading.languages" + :clearable="false" + :append-to-body="false" + :options="availableLanguages" + :placeholder="t('settings', 'No language set')" + :value="userLanguage" + label="name" + @input="setUserLanguage" /> + </template> + <span v-else-if="!isObfuscated"> + {{ userLanguage.name }} + </span> + </td> + + <td v-if="showConfig.showUserBackend || showConfig.showStoragePath" + data-cy-user-list-cell-storage-location + class="row__cell row__cell--large"> + <template v-if="!isObfuscated"> + <span v-if="showConfig.showUserBackend">{{ user.backend }}</span> + <span v-if="showConfig.showStoragePath" + :title="user.storageLocation" + class="row__subtitle"> + {{ user.storageLocation }} + </span> + </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" + data-cy-user-list-cell-last-login> + <span v-if="!isObfuscated">{{ userLastLogin }}</span> + </td> + + <td class="row__cell row__cell--large row__cell--fill" data-cy-user-list-cell-manager> + <template v-if="editing"> + <label class="hidden-visually" + :for="'manager' + uniqueId"> + {{ managerLabel }} + </label> + <NcSelect v-model="currentManager" + class="select--fill" + data-cy-user-list-input-manager + :data-loading="loading.manager || undefined" + :input-id="'manager' + uniqueId" + :disabled="isLoadingField" + :loading="loadingPossibleManagers || loading.manager" + :options="possibleManagers" + :placeholder="managerLabel" + label="displayname" + :filterable="false" + :internal-search="false" + :clearable="true" + @open="searchInitialUserManager" + @search="searchUserManager" + @update:model-value="updateUserManager" /> + </template> + <span v-else-if="!isObfuscated"> + {{ user.manager }} + </span> + </td> + + <td class="row__cell row__cell--actions" data-cy-user-list-cell-actions> + <UserRowActions v-if="visible && !isObfuscated && canEdit && !loading.all" + :actions="userActions" + :disabled="isLoadingField" + :edit="editing" + :user="user" + @update:edit="toggleEdit" /> + </td> + </tr> +</template> + +<script> +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/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 { searchGroups, loadUserGroups, loadUserSubAdminGroups } from '../../service/groups.ts' +import logger from '../../logger.ts' + +export default { + name: 'UserRow', + + components: { + NcAvatar, + NcLoadingIcon, + NcProgressBar, + NcSelect, + NcTextField, + UserRowActions, + }, + + mixins: [ + UserRowMixin, + ], + + props: { + user: { + type: Object, + required: true, + }, + visible: { + type: Boolean, + required: true, + }, + users: { + type: Array, + required: true, + }, + hasObfuscated: { + type: Boolean, + required: true, + }, + quotaOptions: { + type: Array, + required: true, + }, + languages: { + type: Array, + required: true, + }, + settings: { + type: Object, + required: true, + }, + externalActions: { + type: Array, + default: () => [], + }, + }, + + data() { + return { + selectedQuota: false, + rand: Math.random().toString(36).substring(2), + loadingPossibleManagers: false, + possibleManagers: [], + currentManager: '', + editing: false, + loading: { + all: false, + displayName: false, + password: false, + mailAddress: false, + groups: false, + groupsDetails: false, + subAdminGroupsDetails: false, + subadmins: false, + quota: false, + delete: false, + disable: false, + languages: false, + wipe: false, + manager: false, + }, + editedDisplayName: this.user.displayname, + editedPassword: '', + editedMail: this.user.email ?? '', + // Cancelable promise for search groups request + promise: null, + } + }, + + computed: { + managerLabel() { + // TRANSLATORS This string describes a person's manager in the context of an organization + return t('settings', 'Set line manager') + }, + + isObfuscated() { + return isObfuscated(this.user) + }, + + showConfig() { + return this.$store.getters.getShowConfig + }, + + isLoadingUser() { + return this.loading.delete || this.loading.disable || this.loading.wipe + }, + + isLoadingField() { + return this.loading.delete || this.loading.disable || this.loading.all + }, + + uniqueId() { + 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 => { + // 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(', ') + }, + + 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(', ') + }, + + usedSpace() { + if (this.user.quota?.used) { + return t('settings', '{size} used', { size: formatFileSize(this.user.quota?.used) }) + } + return t('settings', '{size} used', { size: formatFileSize(0) }) + }, + + canEdit() { + return getCurrentUser().uid !== this.user.id || this.settings.isAdmin || this.settings.isDelegatedAdmin + }, + + userQuota() { + let quota = this.user.quota?.quota + + if (quota === 'default') { + quota = this.settings.defaultQuota + if (quota !== 'none') { + // convert to numeric value to match what the server would usually return + quota = parseFileSize(quota, true) + } + } + + // when the default quota is unlimited, the server returns -3 here, map it to "none" + if (quota === 'none' || quota === -3) { + return t('settings', 'Unlimited') + } else if (quota >= 0) { + return formatFileSize(quota) + } + return formatFileSize(0) + }, + + userActions() { + const actions = [ + { + icon: 'icon-delete', + text: t('settings', 'Delete account'), + action: this.deleteUser, + }, + { + icon: 'icon-delete', + 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 account') : t('settings', 'Enable account'), + 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) + }, + + // mapping saved values to objects + editedUserQuota: { + get() { + if (this.selectedQuota !== false) { + return this.selectedQuota + } + if (this.settings.defaultQuota !== unlimitedQuota.id && parseFileSize(this.settings.defaultQuota, true) >= 0) { + // if value is valid, let's map the quotaOptions or return custom quota + return { id: this.settings.defaultQuota, label: this.settings.defaultQuota } + } + return unlimitedQuota // unlimited + }, + set(quota) { + this.selectedQuota = quota + }, + }, + + availableLanguages() { + return this.languages[0].languages.concat(this.languages[1].languages) + }, + }, + async beforeMount() { + if (this.user.manager) { + await this.initManager(this.user.manager) + } + }, + + methods: { + 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'), + { + 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(() => showSuccess(t('settings', 'Wiped {userid}\'s devices', { userid })), { timeout: 2000 }) + .finally(() => { + 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 searchInitialUserManager() { + this.loadingPossibleManagers = true + await this.searchUserManager() + 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)) : [] + if (users.length > 0) { + this.possibleManagers = users + } + }) + }, + + 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, + key: 'manager', + value: this.currentManager ? this.currentManager.id : '', + }) + } catch (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 + } + }, + + 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'), + { + 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 + */ + async updateDisplayName() { + this.loading.displayName = true + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'displayname', + value: this.editedDisplayName, + }) + + if (this.editedDisplayName === this.user.displayname) { + showSuccess(t('settings', 'Display name was successfully changed')) + } + } finally { + this.loading.displayName = false + } + }, + + /** + * Set user password + */ + async updatePassword() { + this.loading.password = true + if (this.editedPassword.length === 0) { + showError(t('settings', "Password can't be empty")) + this.loading.password = false + } else { + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'password', + value: this.editedPassword, + }) + this.editedPassword = '' + showSuccess(t('settings', 'Password was successfully changed')) + } finally { + this.loading.password = false + } + } + }, + + /** + * Set user mailAddress + */ + async updateEmail() { + this.loading.mailAddress = true + if (this.editedMail === '') { + showError(t('settings', "Email can't be empty")) + this.loading.mailAddress = false + this.editedMail = this.user.email + } else { + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'email', + value: this.editedMail, + }) + + if (this.editedMail === this.user.email) { + showSuccess(t('settings', 'Email was successfully changed')) + } + } finally { + this.loading.mailAddress = false + } + } + }, + + /** + * Create a new group and add user to it + * + * @param {string} gid Group id + */ + async createGroup({ name: gid }) { + 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) { + logger.error(t('settings', 'Failed to create group'), { error }) + } + this.loading.groups = false + }, + + /** + * Add user to group + * + * @param {object} group Group object + */ + async addUserGroup(group) { + if (group.isCreating) { + // This is NcSelect's internal value for a new inputted group name + // Ignore + return + } + const userid = this.user.id + const gid = group.id + if (group.canAdd === false) { + return + } + this.loading.groups = true + try { + await this.$store.dispatch('addUserGroup', { userid, gid }) + this.userGroups.push(group) + } catch (error) { + console.error(error) + } + 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.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) { + 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.userSubAdminGroups.push(group) + } catch (error) { + console.error(error) + } + this.loading.subadmins = false + }, + + /** + * 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, + }) + this.userSubAdminGroups = this.userSubAdminGroups.filter(group => group.id !== 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') { + // Make sure correct label is set for unlimited quota + if (quota === 'none') { + quota = unlimitedQuota + } + this.loading.quota = true + + // ensure we only send the preset id + quota = quota.id ? quota.id : quota + + try { + // If human readable format, convert to raw float format + // Else just send the raw string + const value = (parseFileSize(quota, true) || quota).toString() + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'quota', + value, + }) + } 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 | object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'} + * @return {object} The validated quota object or unlimited quota if input is invalid + */ + validateQuota(quota) { + if (typeof quota === 'object') { + quota = quota?.id || quota.label + } + // only used for new presets sent through @Tag + const validQuota = parseFileSize(quota, true) + if (validQuota === null) { + return unlimitedQuota + } else { + // unify format output + quota = formatFileSize(parseFileSize(quota, true)) + return { id: quota, label: quota } + } + }, + + /** + * 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, + }) + this.loading.languages = false + } catch (error) { + console.error(error) + } + return lang + }, + + /** + * Dispatch new welcome mail request + */ + sendWelcomeMail() { + this.loading.all = true + this.$store.dispatch('sendWelcomeMail', this.user.id) + .then(() => showSuccess(t('settings', 'Welcome mail sent!'), { timeout: 2000 })) + .finally(() => { + this.loading.all = false + }) + }, + + async toggleEdit() { + this.editing = !this.editing + 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 + } else if (this.editedMail !== this.user.email) { + this.editedMail = this.user.email ?? '' + } + }, + }, +} +</script> + +<style lang="scss" scoped> +@use './shared/styles'; + +.user-list__row { + @include styles.row; + + &:hover { + background-color: var(--color-background-hover); + + .row__cell:not(.row__cell--actions) { + background-color: var(--color-background-hover); + } + } + + // Limit width of select in fill cell + .select--fill { + max-width: calc(var(--cell-width-large) - (2 * var(--cell-padding))); + } +} + +.row { + @include styles.cell; + + &__cell { + border-bottom: 1px solid var(--color-border); + + :deep { + .v-select.select { + min-width: var(--cell-min-width); + } + } + } + + &__progress { + margin-top: 4px; + + &--warn { + &::-moz-progress-bar { + background: var(--color-warning) !important; + } + &::-webkit-progress-value { + background: var(--color-warning) !important; + } + } + } +} +</style> diff --git a/apps/settings/src/components/Users/UserRowActions.vue b/apps/settings/src/components/Users/UserRowActions.vue new file mode 100644 index 00000000000..efd70d879a7 --- /dev/null +++ b/apps/settings/src/components/Users/UserRowActions.vue @@ -0,0 +1,119 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcActions :aria-label="t('settings', 'Toggle account actions menu')" + :disabled="disabled" + :inline="1"> + <NcActionButton :data-cy-user-list-action-toggle-edit="`${edit}`" + :disabled="disabled" + @click="toggleEdit"> + {{ edit ? t('settings', 'Done') : t('settings', 'Edit') }} + <template #icon> + <NcIconSvgWrapper :key="editSvg" :svg="editSvg" aria-hidden="true" /> + </template> + </NcActionButton> + <NcActionButton v-for="({ action, icon, text }, index) in enabledActions" + :key="index" + :disabled="disabled" + :aria-label="text" + :icon="icon" + close-after-click + @click="(event) => action(event, { ...user })"> + {{ text }} + <template v-if="isSvg(icon)" #icon> + <NcIconSvgWrapper :svg="icon" aria-hidden="true" /> + </template> + </NcActionButton> + </NcActions> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import { defineComponent } from 'vue' +import isSvg from 'is-svg' + +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import SvgCheck from '@mdi/svg/svg/check.svg?raw' +import SvgPencil from '@mdi/svg/svg/pencil-outline.svg?raw' + +interface UserAction { + action: (event: MouseEvent, user: Record<string, unknown>) => void, + enabled?: (user: Record<string, unknown>) => boolean, + icon: string, + text: string, +} + +export default defineComponent({ + components: { + NcActionButton, + NcActions, + NcIconSvgWrapper, + }, + + props: { + /** + * Array of user actions + */ + actions: { + type: Array as PropType<readonly UserAction[]>, + required: true, + }, + + /** + * The state whether the row is currently disabled + */ + disabled: { + type: Boolean, + required: true, + }, + + /** + * The state whether the row is currently edited + */ + edit: { + type: Boolean, + required: true, + }, + + /** + * Target of this actions + */ + user: { + type: Object, + required: true, + }, + }, + + computed: { + /** + * Current MDI logo to show for edit toggle + */ + editSvg(): string { + return this.edit ? SvgCheck : SvgPencil + }, + + /** + * Enabled user row actions + */ + enabledActions(): UserAction[] { + return this.actions.filter(action => typeof action.enabled === 'function' ? action.enabled(this.user) : true) + }, + }, + + methods: { + isSvg, + + /** + * Toggle edit mode by emitting the update event + */ + toggleEdit() { + this.$emit('update:edit', !this.edit) + }, + }, +}) +</script> diff --git a/apps/settings/src/components/Users/UserSettingsDialog.vue b/apps/settings/src/components/Users/UserSettingsDialog.vue new file mode 100644 index 00000000000..94c77d320dd --- /dev/null +++ b/apps/settings/src/components/Users/UserSettingsDialog.vue @@ -0,0 +1,337 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcAppSettingsDialog :open.sync="isModalOpen" + :show-navigation="true" + :name="t('settings', 'Account management settings')"> + <NcAppSettingsSection id="visibility-settings" + :name="t('settings', 'Visibility')"> + <NcCheckboxRadioSwitch type="switch" + data-test="showLanguages" + :checked.sync="showLanguages"> + {{ t('settings', 'Show language') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch type="switch" + data-test="showUserBackend" + :checked.sync="showUserBackend"> + {{ t('settings', 'Show account backend') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch type="switch" + data-test="showStoragePath" + :checked.sync="showStoragePath"> + {{ t('settings', 'Show storage path') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch type="switch" + data-test="showFirstLogin" + :checked.sync="showFirstLogin"> + {{ t('settings', 'Show first login') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch type="switch" + data-test="showLastLogin" + :checked.sync="showLastLogin"> + {{ t('settings', 'Show last login') }} + </NcCheckboxRadioSwitch> + </NcAppSettingsSection> + + <NcAppSettingsSection id="groups-sorting" + :name="t('settings', 'Sorting')"> + <NcNoteCard v-if="isGroupSortingEnforced" type="warning"> + {{ t('settings', 'The system config enforces sorting the groups by name. This also disables showing the member count.') }} + </NcNoteCard> + <fieldset> + <legend>{{ t('settings', 'Group list sorting') }}</legend> + <NcNoteCard class="dialog__note" + type="info" + :text="t('settings', 'Sorting only applies to the currently loaded groups for performance reasons. Groups will be loaded as you navigate or search through the list.')" /> + <NcCheckboxRadioSwitch type="radio" + :checked.sync="groupSorting" + data-test="sortGroupsByMemberCount" + :disabled="isGroupSortingEnforced" + name="group-sorting-mode" + value="member-count"> + {{ t('settings', 'By member count') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch type="radio" + :checked.sync="groupSorting" + data-test="sortGroupsByName" + :disabled="isGroupSortingEnforced" + name="group-sorting-mode" + value="name"> + {{ t('settings', 'By name') }} + </NcCheckboxRadioSwitch> + </fieldset> + </NcAppSettingsSection> + + <NcAppSettingsSection id="email-settings" + :name="t('settings', 'Send email')"> + <NcCheckboxRadioSwitch type="switch" + data-test="sendWelcomeMail" + :checked.sync="sendWelcomeMail" + :disabled="loadingSendMail"> + {{ t('settings', 'Send welcome email to new accounts') }} + </NcCheckboxRadioSwitch> + </NcAppSettingsSection> + + <NcAppSettingsSection id="default-settings" + :name="t('settings', 'Defaults')"> + <NcSelect v-model="defaultQuota" + :clearable="false" + :create-option="validateQuota" + :filter-by="filterQuotas" + :input-label="t('settings', 'Default quota')" + :options="quotaOptions" + placement="top" + :placeholder="t('settings', 'Select default quota')" + taggable + @option:selected="setDefaultQuota" /> + </NcAppSettingsSection> + </NcAppSettingsDialog> +</template> + +<script> +import { formatFileSize, parseFileSize } from '@nextcloud/files' +import { generateUrl } from '@nextcloud/router' + +import axios from '@nextcloud/axios' +import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog' +import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcSelect from '@nextcloud/vue/components/NcSelect' + +import { GroupSorting } from '../../constants/GroupManagement.ts' +import { unlimitedQuota } from '../../utils/userUtils.ts' +import logger from '../../logger.ts' + +export default { + name: 'UserSettingsDialog', + + components: { + NcAppSettingsDialog, + NcAppSettingsSection, + NcCheckboxRadioSwitch, + NcNoteCard, + NcSelect, + }, + + props: { + open: { + type: Boolean, + required: true, + }, + }, + + data() { + return { + selectedQuota: false, + loadingSendMail: false, + } + }, + + computed: { + groupSorting: { + get() { + return this.$store.getters.getGroupSorting === GroupSorting.GroupName ? 'name' : 'member-count' + }, + set(sorting) { + this.$store.commit('setGroupSorting', sorting === 'name' ? GroupSorting.GroupName : GroupSorting.UserCount) + }, + }, + + /** + * Admin has configured `sort_groups_by_name` in the system config + */ + isGroupSortingEnforced() { + return this.$store.getters.getServerData.forceSortGroupByName + }, + + isModalOpen: { + get() { + return this.open + }, + set(open) { + this.$emit('update:open', open) + }, + }, + + showConfig() { + return this.$store.getters.getShowConfig + }, + + settings() { + return this.$store.getters.getServerData + }, + + showLanguages: { + get() { + return this.showConfig.showLanguages + }, + set(status) { + this.setShowConfig('showLanguages', status) + }, + }, + + showFirstLogin: { + get() { + return this.showConfig.showFirstLogin + }, + set(status) { + this.setShowConfig('showFirstLogin', status) + }, + }, + + showLastLogin: { + get() { + return this.showConfig.showLastLogin + }, + set(status) { + this.setShowConfig('showLastLogin', status) + }, + }, + + showUserBackend: { + get() { + return this.showConfig.showUserBackend + }, + set(status) { + this.setShowConfig('showUserBackend', status) + }, + }, + + showStoragePath: { + get() { + return this.showConfig.showStoragePath + }, + set(status) { + this.setShowConfig('showStoragePath', status) + }, + }, + + 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) + } + return quotaPreset + }, + + defaultQuota: { + get() { + if (this.selectedQuota !== false) { + return this.selectedQuota + } + if (this.settings.defaultQuota !== unlimitedQuota.id && OC.Util.computerFileSize(this.settings.defaultQuota) >= 0) { + // if value is valid, let's map the quotaOptions or return custom quota + return { id: this.settings.defaultQuota, label: this.settings.defaultQuota } + } + return unlimitedQuota // unlimited + }, + set(quota) { + this.selectedQuota = quota + }, + }, + + sendWelcomeMail: { + get() { + return this.settings.newUserSendEmail + }, + async set(value) { + try { + this.loadingSendMail = true + this.$store.commit('setServerData', { + ...this.settings, + newUserSendEmail: value, + }) + await axios.post(generateUrl('/settings/users/preferences/newUser.sendEmail'), { value: value ? 'yes' : 'no' }) + } catch (error) { + logger.error('Could not update newUser.sendEmail preference', { error }) + } finally { + this.loadingSendMail = false + } + }, + }, + }, + + methods: { + /** + * Check if a quota matches the current search. + * This is a custom filter function to allow to map "1GB" to the label "1 GB" (ignoring whitespaces). + * + * @param option The quota to check + * @param label The label of the quota + * @param search The search string + */ + filterQuotas(option, label, search) { + const searchValue = search.toLocaleLowerCase().replaceAll(/\s/g, '') + return (label || '') + .toLocaleLowerCase() + .replaceAll(/\s/g, '') + .indexOf(searchValue) > -1 + }, + + setShowConfig(key, status) { + this.$store.commit('setShowConfig', { key, value: status }) + }, + + /** + * Validate quota string to make sure it's a valid human file size + * + * @param {string | object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'} + * @return {object} The validated quota object or unlimited quota if input is invalid + */ + validateQuota(quota) { + if (typeof quota === 'object') { + quota = quota?.id || quota.label + } + // only used for new presets sent through @Tag + const validQuota = parseFileSize(quota, true) + if (validQuota === null) { + return unlimitedQuota + } + // unify format output + quota = formatFileSize(validQuota) + return { id: quota, label: quota } + }, + + /** + * Dispatch default quota set request + * + * @param {string | object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'} + */ + setDefaultQuota(quota = 'none') { + // Make sure correct label is set for unlimited quota + if (quota === 'none') { + quota = unlimitedQuota + } + this.$store.dispatch('setAppConfig', { + app: 'files', + key: 'default_quota', + // ensure we only send the preset id + value: quota.id ? quota.id : quota, + }).then(() => { + if (typeof quota !== 'object') { + quota = { id: quota, label: quota } + } + this.defaultQuota = quota + }) + }, + }, +} +</script> + +<style scoped lang="scss"> +.dialog { + &__note { + font-weight: normal; + } +} + +fieldset { + font-weight: bold; +} +</style> diff --git a/apps/settings/src/components/Users/VirtualList.vue b/apps/settings/src/components/Users/VirtualList.vue new file mode 100644 index 00000000000..20dc70ef830 --- /dev/null +++ b/apps/settings/src/components/Users/VirtualList.vue @@ -0,0 +1,184 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <table class="user-list"> + <slot name="before" /> + + <thead ref="thead" + role="rowgroup" + class="user-list__header"> + <slot name="header" /> + </thead> + + <tbody :style="tbodyStyle" + class="user-list__body"> + <component :is="dataComponent" + v-for="(item, i) in renderedItems" + :key="item[dataKey]" + :user="item" + :visible="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)" + v-bind="extraProps" /> + </tbody> + + <tfoot ref="tfoot" + v-element-visibility="handleFooterVisibility" + role="rowgroup" + class="user-list__footer"> + <slot name="footer" /> + </tfoot> + </table> +</template> + +<script lang="ts"> +import Vue from 'vue' +import { vElementVisibility } from '@vueuse/components' +import debounce from 'debounce' + +import logger from '../../logger.ts' + +Vue.directive('elementVisibility', vElementVisibility) + +// Items to render before and after the visible area +const bufferItems = 3 + +export default Vue.extend({ + name: 'VirtualList', + + props: { + dataComponent: { + type: [Object, Function], + required: true, + }, + dataKey: { + type: String, + required: true, + }, + dataSources: { + type: Array, + required: true, + }, + itemHeight: { + type: Number, + required: true, + }, + extraProps: { + type: Object, + default: () => ({}), + }, + }, + + data() { + return { + bufferItems, + index: 0, + headerHeight: 0, + tableHeight: 0, + resizeObserver: null as ResizeObserver | null, + } + }, + + computed: { + startIndex() { + return Math.max(0, this.index - bufferItems) + }, + + shownItems() { + return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2 + }, + + renderedItems() { + return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) + }, + + tbodyStyle() { + const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length + const lastIndex = this.dataSources.length - this.startIndex - this.shownItems + const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex) + return { + paddingTop: `${this.startIndex * this.itemHeight}px`, + paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`, + } + }, + }, + + mounted() { + const root = this.$el as HTMLElement + const tfoot = this.$refs?.tfoot as HTMLElement + const thead = this.$refs?.thead as HTMLElement + + this.resizeObserver = new ResizeObserver(debounce(() => { + this.headerHeight = thead?.clientHeight ?? 0 + this.tableHeight = root?.clientHeight ?? 0 + logger.debug('VirtualList resizeObserver updated') + this.onScroll() + }, 100, false)) + + this.resizeObserver.observe(root) + this.resizeObserver.observe(tfoot) + this.resizeObserver.observe(thead) + + this.$el.addEventListener('scroll', this.onScroll) + }, + + beforeDestroy() { + if (this.resizeObserver) { + this.resizeObserver.disconnect() + } + }, + + methods: { + handleFooterVisibility(visible: boolean) { + if (visible) { + this.$emit('scroll-end') + } + }, + + onScroll() { + // Max 0 to prevent negative index + this.index = Math.max(0, Math.round(this.$el.scrollTop / this.itemHeight)) + }, + }, +}) +</script> + +<style lang="scss" scoped> +.user-list { + --avatar-cell-width: 48px; + --cell-padding: 7px; + --cell-width: 200px; + --cell-width-large: 300px; + --cell-min-width: calc(var(--cell-width) - (2 * var(--cell-padding))); + --sticky-column-z-index: calc(var(--vs-dropdown-z-index) + 1); // Keep the sticky column on top of the select dropdown + + // Necessary for virtual scroll optimized rendering + display: block; + overflow: auto; + height: 100%; + will-change: scroll-position; + + &__header, + &__footer { + position: sticky; + // Fix sticky positioning in Firefox + display: block; + } + + &__header { + top: 0; + z-index: calc(var(--sticky-column-z-index) + 1); + } + + &__footer { + inset-inline-start: 0; + } + + &__body { + display: flex; + flex-direction: column; + width: 100%; + } +} +</style> diff --git a/apps/settings/src/components/Users/shared/styles.scss b/apps/settings/src/components/Users/shared/styles.scss new file mode 100644 index 00000000000..4dfdd58af6d --- /dev/null +++ b/apps/settings/src/components/Users/shared/styles.scss @@ -0,0 +1,110 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +@mixin row { + position: relative; + display: flex; + min-width: 100%; + width: fit-content; + height: var(--row-height); + background-color: var(--color-main-background); +} + +@mixin cell { + &__cell { + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 var(--cell-padding); + min-width: var(--cell-width); + width: var(--cell-width); + color: var(--color-main-text); + + strong, + span, + label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + overflow-wrap: anywhere; + } + + @media (min-width: 670px) { /* Show one &--large column between stickied columns */ + &--avatar, + &--displayname { + position: sticky; + z-index: var(--sticky-column-z-index); + background-color: var(--color-main-background); + } + + &--avatar { + inset-inline-start: 0; + } + + &--displayname { + inset-inline-start: var(--avatar-cell-width); + border-inline-end: 1px solid var(--color-border); + } + } + + &--username { + padding-inline-start: calc(var(--default-grid-baseline) * 3); + } + + &--avatar { + min-width: var(--avatar-cell-width); + width: var(--avatar-cell-width); + align-items: center; + padding: 0; + user-select: none; + } + + &--multiline { + span { + line-height: 1.3em; + white-space: unset; + + @supports (-webkit-line-clamp: 2) { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + } + } + + &--large { + min-width: var(--cell-width-large); + width: var(--cell-width-large); + } + + &--obfuscated { + min-width: 400px; + width: 400px; + } + + // Fill remaining row space with cell + &--fill { + min-width: var(--cell-width-large); + width: 100%; + } + + &--actions { + position: sticky; + inset-inline-end: 0; + z-index: var(--sticky-column-z-index); + display: flex; + flex-direction: row; + align-items: center; + min-width: 110px; + width: 110px; + background-color: var(--color-main-background); + border-inline-start: 1px solid var(--color-border); + } + } + + &__subtitle { + color: var(--color-text-maxcontrast); + } +} |