diff options
Diffstat (limited to 'apps/settings/src/components/Users')
8 files changed, 353 insertions, 183 deletions
diff --git a/apps/settings/src/components/Users/NewUserDialog.vue b/apps/settings/src/components/Users/NewUserDialog.vue index f7dc0d21a34..ef401b565fa 100644 --- a/apps/settings/src/components/Users/NewUserDialog.vue +++ b/apps/settings/src/components/Users/NewUserDialog.vue @@ -61,32 +61,36 @@ :required="newUser.password === '' || settings.newUserRequireEmail" /> <div class="dialog__item"> <NcSelect class="dialog__select" - :input-label="!settings.isAdmin ? t('settings', 'Member of the following groups (required)') : t('settings', 'Member of the following groups')" + 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="canAddGroups" + :options="availableGroups" :value="newUser.groups" label="name" :close-on-select="false" :multiple="true" - :taggable="true" - :required="!settings.isAdmin" - @input="handleGroupInput" - @option:created="createGroup" /> - <!-- If user is not admin, he is a subadmin. + :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 v-if="subAdminsGroups.length > 0" - class="dialog__item"> + <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 …')" - :options="subAdminsGroups" + :disabled="loading.groups || loading.all" + :options="availableGroups" :close-on-select="false" :multiple="true" - label="name" /> + label="name" + @search="searchGroups" /> </div> <div class="dialog__item"> <NcSelect v-model="newUser.quota" @@ -100,7 +104,7 @@ </div> <div v-if="showConfig.showLanguages" class="dialog__item"> - <NcSelect v-model="newUser.language" + <NcSelect v-model="newUser.language" class="dialog__select" :input-label="t('settings', 'Language')" :placeholder="t('settings', 'Set default language')" @@ -135,11 +139,15 @@ </template> <script> -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' -import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +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', @@ -175,7 +183,9 @@ export default { // 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 account manager'), + managerLabel: t('settings', 'Set line manager'), + // Cancelable promise for search groups request + promise: null, } }, @@ -199,27 +209,12 @@ export default { return this.$store.getters.getPasswordPolicyMinLength }, - groups() { - // data provided php side + remove the disabled group - return this.$store.getters.getGroups - .filter(group => group.id !== 'disabled') - .sort((a, b) => a.name.localeCompare(b.name)) - }, - - subAdminsGroups() { - // data provided php side - return this.$store.getters.getSubadminGroups - }, + availableGroups() { + const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin) + ? this.$store.getters.getSortedGroups + : this.$store.getters.getSubAdminGroups - canAddGroups() { - // disabled if no permission to add new users to group - return this.groups.map(group => { - // clone object because we don't want - // to edit the original groups - group = Object.assign({}, group) - group.$isDisabled = group.canAdd === false - return group - }) + return groups.filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled') }, languages() { @@ -280,13 +275,32 @@ export default { } }, - handleGroupInput(groups) { - /** - * Filter out groups with no id to prevent duplicate selected options - * - * Created groups are added programmatically by `createGroup()` - */ - this.newUser.groups = groups.filter(group => Boolean(group.id)) + 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) }, /** @@ -299,11 +313,26 @@ export default { this.loading.groups = true try { await this.$store.dispatch('addGroup', gid) - this.newUser.groups.push(this.groups.find(group => group.id === gid)) - this.loading.groups = false + this.newUser.groups.push({ id: gid, name: gid }) } catch (error) { - this.loading.groups = false + 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) }, /** @@ -317,7 +346,7 @@ export default { const validQuota = OC.Util.computerFileSize(quota) if (validQuota !== null && validQuota >= 0) { // unify format output - quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota)) + quota = formatFileSize(parseFileSize(quota, true)) this.newUser.quota = { id: quota, label: quota } return this.newUser.quota } diff --git a/apps/settings/src/components/Users/UserListFooter.vue b/apps/settings/src/components/Users/UserListFooter.vue index bfe01b1fcce..bf9aa43b6d3 100644 --- a/apps/settings/src/components/Users/UserListFooter.vue +++ b/apps/settings/src/components/Users/UserListFooter.vue @@ -26,7 +26,7 @@ <script lang="ts"> import Vue from 'vue' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import { translate as t, @@ -84,18 +84,18 @@ export default Vue.extend({ </script> <style lang="scss" scoped> -@import './shared/styles.scss'; +@use './shared/styles'; .footer { - @include row; - @include cell; + @include styles.row; + @include styles.cell; &__cell { position: sticky; color: var(--color-text-maxcontrast); &--loading { - left: 0; + inset-inline-start: 0; min-width: var(--avatar-cell-width); width: var(--avatar-cell-width); align-items: center; @@ -103,7 +103,7 @@ export default Vue.extend({ } &--count { - left: var(--avatar-cell-width); + inset-inline-start: var(--avatar-cell-width); min-width: var(--cell-width); width: var(--cell-width); } diff --git a/apps/settings/src/components/Users/UserListHeader.vue b/apps/settings/src/components/Users/UserListHeader.vue index 44eb1a6b7ae..a85306d84d3 100644 --- a/apps/settings/src/components/Users/UserListHeader.vue +++ b/apps/settings/src/components/Users/UserListHeader.vue @@ -42,7 +42,7 @@ scope="col"> <span>{{ t('settings', 'Groups') }}</span> </th> - <th v-if="subAdminsGroups.length > 0 && settings.isAdmin" + <th v-if="settings.isAdmin || settings.isDelegatedAdmin" class="header__cell header__cell--large" data-cy-user-list-header-subadmins scope="col"> @@ -71,6 +71,12 @@ {{ 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 @@ -119,11 +125,6 @@ export default Vue.extend({ return this.$store.getters.getServerData }, - subAdminsGroups() { - // @ts-expect-error: allow untyped $store - return this.$store.getters.getSubadminGroups - }, - 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 @@ -140,12 +141,12 @@ export default Vue.extend({ </script> <style lang="scss" scoped> -@import './shared/styles.scss'; +@use './shared/styles'; .header { - @include row; - @include cell; - 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 index 4a4a221e199..43668725972 100644 --- a/apps/settings/src/components/Users/UserRow.vue +++ b/apps/settings/src/components/Users/UserRow.vue @@ -106,17 +106,18 @@ :data-loading="loading.groups || undefined" :input-id="'groups' + uniqueId" :close-on-select="false" - :disabled="isLoadingField" + :disabled="isLoadingField || loading.groupsDetails" :loading="loading.groups" :multiple="true" :append-to-body="false" :options="availableGroups" :placeholder="t('settings', 'Add account to group')" - :taggable="settings.isAdmin" + :taggable="settings.isAdmin || settings.isDelegatedAdmin" :value="userGroups" label="name" :no-wrap="true" - :create-option="(value) => ({ name: value, isCreating: true })" + :create-option="(value) => ({ id: value, name: value, isCreating: true })" + @search="searchGroups" @option:created="createGroup" @option:selected="options => addUserGroup(options.at(-1))" @option:deselected="removeUserGroup" /> @@ -127,10 +128,10 @@ </span> </td> - <td v-if="subAdminsGroups.length > 0 && settings.isAdmin" + <td v-if="settings.isAdmin || settings.isDelegatedAdmin" data-cy-user-list-cell-subadmins class="row__cell row__cell--large row__cell--multiline"> - <template v-if="editing && settings.isAdmin && subAdminsGroups.length > 0"> + <template v-if="editing && (settings.isAdmin || settings.isDelegatedAdmin)"> <label class="hidden-visually" :for="'subadmins' + uniqueId"> {{ t('settings', 'Set account as admin for') }} @@ -139,21 +140,22 @@ :data-loading="loading.subadmins || undefined" :input-id="'subadmins' + uniqueId" :close-on-select="false" - :disabled="isLoadingField" + :disabled="isLoadingField || loading.subAdminGroupsDetails" :loading="loading.subadmins" label="name" :append-to-body="false" :multiple="true" :no-wrap="true" - :options="subAdminsGroups" + :options="availableSubAdminGroups" :placeholder="t('settings', 'Set account as admin for')" - :value="userSubAdminsGroups" + :value="userSubAdminGroups" + @search="searchGroups" @option:deselected="removeUserSubAdmin" @option:selected="options => addUserSubAdmin(options.at(-1))" /> </template> <span v-else-if="!isObfuscated" - :title="userSubAdminsGroupsLabels?.length > 40 ? userSubAdminsGroupsLabels : null"> - {{ userSubAdminsGroupsLabels }} + :title="userSubAdminGroupsLabels?.length > 40 ? userSubAdminGroupsLabels : null"> + {{ userSubAdminGroupsLabels }} </span> </td> @@ -229,6 +231,12 @@ </template> </td> + <td v-if="showConfig.showFirstLogin" + class="row__cell" + data-cy-user-list-cell-first-login> + <span v-if="!isObfuscated">{{ userFirstLogin }}</span> + </td> + <td v-if="showConfig.showLastLogin" :title="userLastLoginTooltip" class="row__cell" @@ -247,16 +255,17 @@ data-cy-user-list-input-manager :data-loading="loading.manager || undefined" :input-id="'manager' + uniqueId" - :close-on-select="true" :disabled="isLoadingField" - :append-to-body="false" :loading="loadingPossibleManagers || loading.manager" - label="displayname" :options="possibleManagers" :placeholder="managerLabel" + label="displayname" + :filterable="false" + :internal-search="false" + :clearable="true" @open="searchInitialUserManager" @search="searchUserManager" - @option:selected="updateUserManager" /> + @update:model-value="updateUserManager" /> </template> <span v-else-if="!isObfuscated"> {{ user.manager }} @@ -278,17 +287,20 @@ import { formatFileSize, parseFileSize } from '@nextcloud/files' import { getCurrentUser } from '@nextcloud/auth' import { showSuccess, showError } from '@nextcloud/dialogs' +import { confirmPassword } from '@nextcloud/password-confirmation' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcProgressBar from '@nextcloud/vue/components/NcProgressBar' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcTextField from '@nextcloud/vue/components/NcTextField' import UserRowActions from './UserRowActions.vue' import UserRowMixin from '../../mixins/UserRowMixin.js' -import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts'; +import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts' +import { searchGroups, loadUserGroups, loadUserSubAdminGroups } from '../../service/groups.ts' +import logger from '../../logger.ts' export default { name: 'UserRow', @@ -323,14 +335,6 @@ export default { type: Boolean, required: true, }, - groups: { - type: Array, - default: () => [], - }, - subAdminsGroups: { - type: Array, - required: true, - }, quotaOptions: { type: Array, required: true, @@ -363,6 +367,8 @@ export default { password: false, mailAddress: false, groups: false, + groupsDetails: false, + subAdminGroupsDetails: false, subadmins: false, quota: false, delete: false, @@ -374,6 +380,8 @@ export default { editedDisplayName: this.user.displayname, editedPassword: '', editedMail: this.user.email ?? '', + // Cancelable promise for search groups request + promise: null, } }, @@ -403,15 +411,35 @@ export default { return encodeURIComponent(this.user.id + this.rand) }, + availableGroups() { + const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin) + ? this.$store.getters.getSortedGroups + : this.$store.getters.getSubAdminGroups + + return groups.filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled') + }, + + availableSubAdminGroups() { + return this.availableGroups.filter(group => group.id !== 'admin') + }, + userGroupsLabels() { return this.userGroups - .map(group => group.name) + .map(group => { + // Try to match with more extensive group data + const availableGroup = this.availableGroups.find(g => g.id === group.id) + return availableGroup?.name ?? group.name ?? group.id + }) .join(', ') }, - userSubAdminsGroupsLabels() { - return this.userSubAdminsGroups - .map(group => group.name) + userSubAdminGroupsLabels() { + return this.userSubAdminGroups + .map(group => { + // Try to match with more extensive group data + const availableGroup = this.availableSubAdminGroups.find(g => g.id === group.id) + return availableGroup?.name ?? group.name ?? group.id + }) .join(', ') }, @@ -423,7 +451,7 @@ export default { }, canEdit() { - return getCurrentUser().uid !== this.user.id || this.settings.isAdmin + return getCurrentUser().uid !== this.user.id || this.settings.isAdmin || this.settings.isDelegatedAdmin }, userQuota() { @@ -495,7 +523,6 @@ export default { return this.languages[0].languages.concat(this.languages[1].languages) }, }, - async beforeMount() { if (this.user.manager) { await this.initManager(this.user.manager) @@ -503,8 +530,9 @@ export default { }, methods: { - wipeUserDevices() { + async wipeUserDevices() { const userid = this.user.id + await confirmPassword() OC.dialogs.confirmDestructive( t('settings', 'In case of lost device or exiting the organization, this can remotely wipe the Nextcloud data from all devices associated with {userid}. Only works if the devices are connected to the internet.', { userid }), t('settings', 'Remote wipe of devices'), @@ -546,6 +574,66 @@ export default { this.loadingPossibleManagers = false }, + async loadGroupsDetails() { + this.loading.groups = true + this.loading.groupsDetails = true + try { + const groups = await loadUserGroups({ userId: this.user.id }) + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + this.selectedGroups = this.selectedGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup) + } catch (error) { + logger.error(t('settings', 'Failed to load groups with details'), { error }) + } + this.loading.groups = false + this.loading.groupsDetails = false + }, + + async loadSubAdminGroupsDetails() { + this.loading.subadmins = true + this.loading.subAdminGroupsDetails = true + try { + const groups = await loadUserSubAdminGroups({ userId: this.user.id }) + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + this.selectedSubAdminGroups = this.selectedSubAdminGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup) + } catch (error) { + logger.error(t('settings', 'Failed to load sub admin groups with details'), { error }) + } + this.loading.subadmins = false + this.loading.subAdminGroupsDetails = false + }, + + async searchGroups(query, toggleLoading) { + if (query === '') { + return // Prevent unexpected search behaviour e.g. on option:created + } + if (this.promise) { + this.promise.cancel() + } + toggleLoading(true) + try { + this.promise = await searchGroups({ + search: query, + offset: 0, + limit: 25, + }) + const groups = await this.promise + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + } catch (error) { + logger.error(t('settings', 'Failed to search groups'), { error }) + } + this.promise = null + toggleLoading(false) + }, + async searchUserManager(query) { await this.$store.dispatch('searchUsers', { offset: 0, limit: 10, search: query }).then(response => { const users = response?.data ? this.filterManagers(Object.values(response?.data.ocs.data.users)) : [] @@ -555,11 +643,12 @@ export default { }) }, - async updateUserManager(manager) { - if (manager === null) { - this.currentManager = '' - } + async updateUserManager() { this.loading.manager = true + + // Store the current manager before making changes + const previousManager = this.user.manager + try { await this.$store.dispatch('setUserData', { userid: this.user.id, @@ -568,15 +657,19 @@ export default { }) } catch (error) { // TRANSLATORS This string describes a line manager in the context of an organization - showError(t('setting', 'Failed to update line manager')) - console.error(error) + showError(t('settings', 'Failed to update line manager')) + logger.error('Failed to update manager:', { error }) + + // Revert to the previous manager in the UI on error + this.currentManager = previousManager } finally { this.loading.manager = false } }, - deleteUser() { + async deleteUser() { const userid = this.user.id + await confirmPassword() OC.dialogs.confirmDestructive( t('settings', 'Fully delete {userid}\'s account including all their personal files, app data, etc.', { userid }), t('settings', 'Account deletion'), @@ -618,68 +711,70 @@ export default { /** * Set user displayName - * - * @param {string} displayName The display name */ - updateDisplayName() { + async updateDisplayName() { this.loading.displayName = true - this.$store.dispatch('setUserData', { - userid: this.user.id, - key: 'displayname', - value: this.editedDisplayName, - }).then(() => { - this.loading.displayName = false + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'displayname', + value: this.editedDisplayName, + }) + if (this.editedDisplayName === this.user.displayname) { - showSuccess(t('setting', 'Display name was successfully changed')) + showSuccess(t('settings', 'Display name was successfully changed')) } - }) + } finally { + this.loading.displayName = false + } }, /** * Set user password - * - * @param {string} password The email address */ - updatePassword() { + async updatePassword() { this.loading.password = true if (this.editedPassword.length === 0) { - showError(t('setting', "Password can't be empty")) + showError(t('settings', "Password can't be empty")) this.loading.password = false } else { - this.$store.dispatch('setUserData', { - userid: this.user.id, - key: 'password', - value: this.editedPassword, - }).then(() => { - this.loading.password = false + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'password', + value: this.editedPassword, + }) this.editedPassword = '' - showSuccess(t('setting', 'Password was successfully changed')) - }) + showSuccess(t('settings', 'Password was successfully changed')) + } finally { + this.loading.password = false + } } }, /** * Set user mailAddress - * - * @param {string} mailAddress The email address */ - updateEmail() { + async updateEmail() { this.loading.mailAddress = true if (this.editedMail === '') { - showError(t('setting', "Email can't be empty")) + showError(t('settings', "Email can't be empty")) this.loading.mailAddress = false this.editedMail = this.user.email } else { - this.$store.dispatch('setUserData', { - userid: this.user.id, - key: 'email', - value: this.editedMail, - }).then(() => { - this.loading.mailAddress = false + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'email', + value: this.editedMail, + }) + if (this.editedMail === this.user.email) { - showSuccess(t('setting', 'Email was successfully changed')) + showSuccess(t('settings', 'Email was successfully changed')) } - }) + } finally { + this.loading.mailAddress = false + } } }, @@ -689,17 +784,16 @@ export default { * @param {string} gid Group id */ async createGroup({ name: gid }) { - this.loading = { groups: true, subadmins: true } + this.loading.groups = true try { await this.$store.dispatch('addGroup', gid) const userid = this.user.id await this.$store.dispatch('addUserGroup', { userid, gid }) + this.userGroups.push({ id: gid, name: gid }) } catch (error) { - console.error(error) - } finally { - this.loading = { groups: false, subadmins: false } + logger.error(t('settings', 'Failed to create group'), { error }) } - return this.$store.getters.getGroups[this.groups.length] + this.loading.groups = false }, /** @@ -713,19 +807,19 @@ export default { // Ignore return } - this.loading.groups = true const userid = this.user.id const gid = group.id if (group.canAdd === false) { - return false + return } + this.loading.groups = true try { await this.$store.dispatch('addUserGroup', { userid, gid }) + this.userGroups.push(group) } catch (error) { console.error(error) - } finally { - this.loading.groups = false } + this.loading.groups = false }, /** @@ -745,6 +839,7 @@ export default { userid, gid, }) + this.userGroups = this.userGroups.filter(group => group.id !== gid) this.loading.groups = false // remove user from current list if current list is the removed group if (this.$route.params.selectedGroup === gid) { @@ -769,10 +864,11 @@ export default { userid, gid, }) - this.loading.subadmins = false + this.userSubAdminGroups.push(group) } catch (error) { console.error(error) } + this.loading.subadmins = false }, /** @@ -790,6 +886,7 @@ export default { userid, gid, }) + this.userSubAdminGroups = this.userSubAdminGroups.filter(group => group.id !== gid) } catch (error) { console.error(error) } finally { @@ -879,7 +976,7 @@ export default { sendWelcomeMail() { this.loading.all = true this.$store.dispatch('sendWelcomeMail', this.user.id) - .then(() => showSuccess(t('setting', 'Welcome mail sent!'), { timeout: 2000 })) + .then(() => showSuccess(t('settings', 'Welcome mail sent!'), { timeout: 2000 })) .finally(() => { this.loading.all = false }) @@ -890,6 +987,8 @@ export default { if (this.editing) { await this.$nextTick() this.$refs.displayNameField?.$refs?.inputField?.$refs?.input?.focus() + this.loadGroupsDetails() + this.loadSubAdminGroupsDetails() } if (this.editedDisplayName !== this.user.displayname) { this.editedDisplayName = this.user.displayname @@ -902,10 +1001,10 @@ export default { </script> <style lang="scss" scoped> -@import './shared/styles.scss'; +@use './shared/styles'; .user-list__row { - @include row; + @include styles.row; &:hover { background-color: var(--color-background-hover); @@ -922,7 +1021,7 @@ export default { } .row { - @include cell; + @include styles.cell; &__cell { border-bottom: 1px solid var(--color-border); diff --git a/apps/settings/src/components/Users/UserRowActions.vue b/apps/settings/src/components/Users/UserRowActions.vue index 1bacd79e8a1..efd70d879a7 100644 --- a/apps/settings/src/components/Users/UserRowActions.vue +++ b/apps/settings/src/components/Users/UserRowActions.vue @@ -35,11 +35,11 @@ import type { PropType } from 'vue' import { defineComponent } from 'vue' import isSvg from 'is-svg' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +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.svg?raw' +import SvgPencil from '@mdi/svg/svg/pencil-outline.svg?raw' interface UserAction { action: (event: MouseEvent, user: Record<string, unknown>) => void, diff --git a/apps/settings/src/components/Users/UserSettingsDialog.vue b/apps/settings/src/components/Users/UserSettingsDialog.vue index 4d412146f9a..94c77d320dd 100644 --- a/apps/settings/src/components/Users/UserSettingsDialog.vue +++ b/apps/settings/src/components/Users/UserSettingsDialog.vue @@ -25,6 +25,11 @@ {{ 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') }} @@ -38,6 +43,9 @@ </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" @@ -70,13 +78,14 @@ <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')" - placement="top" - :taggable="true" :options="quotaOptions" - :create-option="validateQuota" + placement="top" :placeholder="t('settings', 'Select default quota')" - :clearable="false" + taggable @option:selected="setDefaultQuota" /> </NcAppSettingsSection> </NcAppSettingsDialog> @@ -87,14 +96,15 @@ import { formatFileSize, parseFileSize } from '@nextcloud/files' import { generateUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' -import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDialog.js' -import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +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', @@ -164,6 +174,15 @@ export default { }, }, + showFirstLogin: { + get() { + return this.showConfig.showFirstLogin + }, + set(status) { + this.setShowConfig('showFirstLogin', status) + }, + }, + showLastLogin: { get() { return this.showConfig.showLastLogin @@ -229,8 +248,8 @@ export default { newUserSendEmail: value, }) await axios.post(generateUrl('/settings/users/preferences/newUser.sendEmail'), { value: value ? 'yes' : 'no' }) - } catch (e) { - console.error('could not update newUser.sendEmail preference: ' + e.message, e) + } catch (error) { + logger.error('Could not update newUser.sendEmail preference', { error }) } finally { this.loadingSendMail = false } @@ -239,6 +258,22 @@ export default { }, 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 }) }, @@ -254,14 +289,13 @@ export default { quota = quota?.id || quota.label } // only used for new presets sent through @Tag - const validQuota = parseFileSize(quota) + const validQuota = parseFileSize(quota, true) if (validQuota === null) { return unlimitedQuota - } else { - // unify format output - quota = formatFileSize(parseFileSize(quota)) - return { id: quota, label: quota } } + // unify format output + quota = formatFileSize(validQuota) + return { id: quota, label: quota } }, /** @@ -291,6 +325,12 @@ export default { </script> <style scoped lang="scss"> +.dialog { + &__note { + font-weight: normal; + } +} + fieldset { font-weight: bold; } diff --git a/apps/settings/src/components/Users/VirtualList.vue b/apps/settings/src/components/Users/VirtualList.vue index 37e82b3a747..20dc70ef830 100644 --- a/apps/settings/src/components/Users/VirtualList.vue +++ b/apps/settings/src/components/Users/VirtualList.vue @@ -157,6 +157,7 @@ export default Vue.extend({ display: block; overflow: auto; height: 100%; + will-change: scroll-position; &__header, &__footer { @@ -171,7 +172,7 @@ export default Vue.extend({ } &__footer { - left: 0; + inset-inline-start: 0; } &__body { diff --git a/apps/settings/src/components/Users/shared/styles.scss b/apps/settings/src/components/Users/shared/styles.scss index 1b7faa78031..4dfdd58af6d 100644 --- a/apps/settings/src/components/Users/shared/styles.scss +++ b/apps/settings/src/components/Users/shared/styles.scss @@ -40,17 +40,17 @@ } &--avatar { - left: 0; + inset-inline-start: 0; } &--displayname { - left: var(--avatar-cell-width); - border-right: 1px solid var(--color-border); + inset-inline-start: var(--avatar-cell-width); + border-inline-end: 1px solid var(--color-border); } } &--username { - padding-left: calc(var(--default-grid-baseline) * 3); + padding-inline-start: calc(var(--default-grid-baseline) * 3); } &--avatar { @@ -92,7 +92,7 @@ &--actions { position: sticky; - right: 0; + inset-inline-end: 0; z-index: var(--sticky-column-z-index); display: flex; flex-direction: row; @@ -100,7 +100,7 @@ min-width: 110px; width: 110px; background-color: var(--color-main-background); - border-left: 1px solid var(--color-border); + border-inline-start: 1px solid var(--color-border); } } |