diff options
Diffstat (limited to 'apps/settings/src/components/Users')
-rw-r--r-- | apps/settings/src/components/Users/NewUserDialog.vue (renamed from apps/settings/src/components/Users/NewUserModal.vue) | 270 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserListFooter.vue | 43 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserListHeader.vue | 54 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserRow.vue | 324 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserRowActions.vue | 51 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserSettingsDialog.vue | 93 | ||||
-rw-r--r-- | apps/settings/src/components/Users/VirtualList.vue | 26 | ||||
-rw-r--r-- | apps/settings/src/components/Users/shared/styles.scss | 35 |
8 files changed, 476 insertions, 420 deletions
diff --git a/apps/settings/src/components/Users/NewUserModal.vue b/apps/settings/src/components/Users/NewUserDialog.vue index 236bc6db7d8..ef401b565fa 100644 --- a/apps/settings/src/components/Users/NewUserModal.vue +++ b/apps/settings/src/components/Users/NewUserDialog.vue @@ -1,36 +1,21 @@ <!-- - - @copyright 2023 Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license AGPL-3.0-or-later - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> - <NcModal class="modal" + <NcDialog class="dialog" size="small" + :name="t('settings', 'New account')" + out-transition v-on="$listeners"> - <form class="modal__form" + <form id="new-user-form" + class="dialog__form" data-test="form" :disabled="loading.all" @submit.prevent="createUser"> - <h2>{{ t('settings', 'New user') }}</h2> <NcTextField ref="username" - class="modal__item" + class="dialog__item" data-test="username" :value.sync="newUser.id" :disabled="settings.newUserGenerateUserID" @@ -40,7 +25,7 @@ spellcheck="false" pattern="[a-zA-Z0-9 _\.@\-']+" required /> - <NcTextField class="modal__item" + <NcTextField class="dialog__item" data-test="displayName" :value.sync="newUser.displayName" :label="t('settings', 'Display name')" @@ -49,11 +34,11 @@ spellcheck="false" /> <span v-if="!settings.newUserRequireEmail" id="password-email-hint" - class="modal__hint"> + class="dialog__hint"> {{ t('settings', 'Either password or email is required') }} </span> <NcPasswordField ref="password" - class="modal__item" + class="dialog__item" data-test="password" :value.sync="newUser.password" :minlength="minPasswordLength" @@ -64,7 +49,7 @@ autocomplete="new-password" spellcheck="false" :required="newUser.mailAddress === ''" /> - <NcTextField class="modal__item" + <NcTextField class="dialog__item" data-test="email" type="email" :value.sync="newUser.mailAddress" @@ -74,66 +59,54 @@ autocomplete="off" spellcheck="false" :required="newUser.password === '' || settings.newUserRequireEmail" /> - <div class="modal__item"> - <label class="modal__label" - for="new-user-groups"> - {{ !settings.isAdmin ? t('settings', 'Groups (required)') : t('settings', 'Groups') }} - </label> - <NcSelect class="modal__select" - input-id="new-user-groups" - :placeholder="t('settings', 'Set user groups')" + <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="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="modal__item"> - <label class="modal__label" - for="new-user-sub-admin"> - {{ t('settings', 'Administered groups') }} - </label> + <div class="dialog__item"> <NcSelect v-model="newUser.subAdminsGroups" - class="modal__select" - input-id="new-user-sub-admin" - :placeholder="t('settings', 'Set user as admin for …')" - :options="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" /> + label="name" + @search="searchGroups" /> </div> - <div class="modal__item"> - <label class="modal__label" - for="new-user-quota"> - {{ t('settings', 'Quota') }} - </label> + <div class="dialog__item"> <NcSelect v-model="newUser.quota" - class="modal__select" - input-id="new-user-quota" - :placeholder="t('settings', 'Set user 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="modal__item"> - <label class="modal__label" - for="new-user-language"> - {{ t('settings', 'Language') }} - </label> - <NcSelect v-model="newUser.language" - class="modal__select" - input-id="new-user-language" + 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" @@ -141,44 +114,47 @@ :options="languages" label="name" /> </div> - <div :class="['modal__item managers', { 'icon-loading-small': loading.manager }]"> - <label class="modal__label" - for="new-user-manager"> - <!-- TRANSLATORS This string describes a manager in the context of an organization --> - {{ t('settings', 'Manager') }} - </label> + <div :class="['dialog__item dialog__managers', { 'icon-loading-small': loading.manager }]"> <NcSelect v-model="newUser.manager" - class="modal__select" - input-id="new-user-manager" + class="dialog__select" + :input-label="managerInputLabel" :placeholder="managerLabel" :options="possibleManagers" :user-select="true" label="displayname" @search="searchUserManager" /> </div> - <NcButton class="modal__submit" + </form> + + <template #actions> + <NcButton class="dialog__submit" data-test="submit" + form="new-user-form" type="primary" native-type="submit"> - {{ t('settings', 'Add new user') }} + {{ t('settings', 'Add new account') }} </NcButton> - </form> - </NcModal> + </template> + </NcDialog> </template> <script> -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcModal from '@nextcloud/vue/dist/Components/NcModal.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: 'NewUserModal', + name: 'NewUserDialog', components: { NcButton, - NcModal, + NcDialog, NcPasswordField, NcSelect, NcTextField, @@ -205,7 +181,11 @@ export default { return { possibleManagers: [], // TRANSLATORS This string describes a manager in the context of an organization - managerLabel: t('settings', 'Set user manager'), + 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, } }, @@ -220,36 +200,21 @@ export default { usernameLabel() { if (this.settings.newUserGenerateUserID) { - return t('settings', 'Username will be autogenerated') + return t('settings', 'Account name will be autogenerated') } - return t('settings', 'Username (required)') + return t('settings', 'Account name (required)') }, minPasswordLength() { 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)) - }, + availableGroups() { + const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin) + ? this.$store.getters.getSortedGroups + : this.$store.getters.getSubAdminGroups - subAdminsGroups() { - // data provided php side - return 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() { @@ -272,6 +237,10 @@ export default { await this.searchUserManager() }, + mounted() { + this.$refs.username?.focus?.() + }, + methods: { async createUser() { this.loading.all = true @@ -289,30 +258,49 @@ export default { }) this.$emit('reset') - this.$refs.username?.$refs?.inputField?.$refs?.input?.focus?.() - this.$emit('close') + 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?.$refs?.inputField?.$refs?.input?.focus?.() + this.$refs.username?.focus?.() } else if (statuscode === 107) { // wrong password - this.$refs.password?.$refs?.inputField?.$refs?.input?.focus?.() + this.$refs.password?.focus?.() } } } }, - 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) }, /** @@ -325,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) }, /** @@ -343,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 } @@ -383,12 +386,12 @@ export default { </script> <style lang="scss" scoped> -.modal { +.dialog { &__form { display: flex; flex-direction: column; align-items: center; - padding: 20px; + padding: 0 8px; gap: 4px 0; } @@ -415,8 +418,19 @@ export default { width: 100%; } + &__managers { + margin-bottom: 12px; + } + &__submit { - margin-top: 20px; + 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 index d8974658354..bf9aa43b6d3 100644 --- a/apps/settings/src/components/Users/UserListFooter.vue +++ b/apps/settings/src/components/Users/UserListFooter.vue @@ -1,23 +1,6 @@ <!-- - - @copyright 2023 Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license AGPL-3.0-or-later - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> @@ -28,7 +11,7 @@ </th> <td class="footer__cell footer__cell--loading"> <NcLoadingIcon v-if="loading" - :title="t('settings', 'Loading users …')" + :title="t('settings', 'Loading accounts …')" :size="32" /> </td> <td class="footer__cell footer__cell--count footer__cell--multiline"> @@ -43,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, @@ -73,8 +56,8 @@ export default Vue.extend({ if (this.loading) { return this.n( 'settings', - '{userCount} user …', - '{userCount} users …', + '{userCount} account …', + '{userCount} accounts …', this.filteredUsers.length, { userCount: this.filteredUsers.length, @@ -83,8 +66,8 @@ export default Vue.extend({ } return this.n( 'settings', - '{userCount} user', - '{userCount} users', + '{userCount} account', + '{userCount} accounts', this.filteredUsers.length, { userCount: this.filteredUsers.length, @@ -101,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; @@ -120,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 e314bcb6a73..a85306d84d3 100644 --- a/apps/settings/src/components/Users/UserListHeader.vue +++ b/apps/settings/src/components/Users/UserListHeader.vue @@ -1,23 +1,6 @@ <!-- - - @copyright 2023 Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license AGPL-3.0-or-later - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> @@ -35,8 +18,12 @@ <strong> {{ t('settings', 'Display name') }} </strong> - <span class="header__subtitle"> - {{ t('settings', 'Username') }} + </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" @@ -55,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"> @@ -77,13 +64,19 @@ data-cy-user-list-header-storage-location scope="col"> <span v-if="showConfig.showUserBackend"> - {{ t('settings', 'User backend') }} + {{ 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 @@ -100,7 +93,7 @@ data-cy-user-list-header-actions scope="col"> <span class="hidden-visually"> - {{ t('settings', 'User actions') }} + {{ t('settings', 'Account actions') }} </span> </th> </tr> @@ -132,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 @@ -153,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 3586a7702b1..43668725972 100644 --- a/apps/settings/src/components/Users/UserRow.vue +++ b/apps/settings/src/components/Users/UserRow.vue @@ -1,27 +1,7 @@ <!-- - - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> - - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - @author Gary Kim <gary@garykim.dev> - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <tr class="user-list__row" @@ -54,13 +34,14 @@ spellcheck="false" @trailing-button-click="updateDisplayName" /> </template> - <template v-else> - <strong v-if="!isObfuscated" - :title="user.displayname?.length > 20 ? user.displayname : null"> - {{ user.displayname }} - </strong> - <span class="row__subtitle">{{ user.id }}</span> - </template> + <strong v-else-if="!isObfuscated" + :title="user.displayname?.length > 20 ? user.displayname : null"> + {{ user.displayname }} + </strong> + </td> + + <td class="row__cell row__cell--username" data-cy-user-list-cell-username> + <span class="row__subtitle">{{ user.id }}</span> </td> <td data-cy-user-list-cell-password @@ -119,23 +100,24 @@ <template v-if="editing"> <label class="hidden-visually" :for="'groups' + uniqueId"> - {{ t('settings', 'Add user to group') }} + {{ t('settings', 'Add account to group') }} </label> <NcSelect data-cy-user-list-input-groups :data-loading="loading.groups || undefined" :input-id="'groups' + uniqueId" :close-on-select="false" - :disabled="isLoadingField" + :disabled="isLoadingField || loading.groupsDetails" :loading="loading.groups" :multiple="true" :append-to-body="false" :options="availableGroups" :placeholder="t('settings', 'Add 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" /> @@ -146,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') }} @@ -158,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> @@ -248,6 +231,12 @@ </template> </td> + <td v-if="showConfig.showFirstLogin" + class="row__cell" + data-cy-user-list-cell-first-login> + <span v-if="!isObfuscated">{{ userFirstLogin }}</span> + </td> + <td v-if="showConfig.showLastLogin" :title="userLastLoginTooltip" class="row__cell" @@ -266,16 +255,17 @@ data-cy-user-list-input-manager :data-loading="loading.manager || undefined" :input-id="'manager' + uniqueId" - :close-on-select="true" :disabled="isLoadingField" - :append-to-body="false" :loading="loadingPossibleManagers || loading.manager" - label="displayname" :options="possibleManagers" :placeholder="managerLabel" + label="displayname" + :filterable="false" + :internal-search="false" + :clearable="true" @open="searchInitialUserManager" @search="searchUserManager" - @option:selected="updateUserManager" /> + @update:model-value="updateUserManager" /> </template> <span v-else-if="!isObfuscated"> {{ user.manager }} @@ -297,17 +287,20 @@ import { formatFileSize, parseFileSize } from '@nextcloud/files' import { getCurrentUser } from '@nextcloud/auth' import { showSuccess, showError } from '@nextcloud/dialogs' +import { confirmPassword } from '@nextcloud/password-confirmation' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcProgressBar from '@nextcloud/vue/components/NcProgressBar' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcTextField from '@nextcloud/vue/components/NcTextField' import UserRowActions from './UserRowActions.vue' import UserRowMixin from '../../mixins/UserRowMixin.js' -import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts'; +import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts' +import { searchGroups, loadUserGroups, loadUserSubAdminGroups } from '../../service/groups.ts' +import logger from '../../logger.ts' export default { name: 'UserRow', @@ -342,14 +335,6 @@ export default { type: Boolean, required: true, }, - groups: { - type: Array, - default: () => [], - }, - subAdminsGroups: { - type: Array, - required: true, - }, quotaOptions: { type: Array, required: true, @@ -382,6 +367,8 @@ export default { password: false, mailAddress: false, groups: false, + groupsDetails: false, + subAdminGroupsDetails: false, subadmins: false, quota: false, delete: false, @@ -393,6 +380,8 @@ export default { editedDisplayName: this.user.displayname, editedPassword: '', editedMail: this.user.email ?? '', + // Cancelable promise for search groups request + promise: null, } }, @@ -422,15 +411,35 @@ export default { return encodeURIComponent(this.user.id + this.rand) }, + availableGroups() { + const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin) + ? this.$store.getters.getSortedGroups + : this.$store.getters.getSubAdminGroups + + return groups.filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled') + }, + + availableSubAdminGroups() { + return this.availableGroups.filter(group => group.id !== 'admin') + }, + userGroupsLabels() { return this.userGroups - .map(group => group.name) + .map(group => { + // Try to match with more extensive group data + const availableGroup = this.availableGroups.find(g => g.id === group.id) + return availableGroup?.name ?? group.name ?? group.id + }) .join(', ') }, - userSubAdminsGroupsLabels() { - return this.userSubAdminsGroups - .map(group => group.name) + userSubAdminGroupsLabels() { + return this.userSubAdminGroups + .map(group => { + // Try to match with more extensive group data + const availableGroup = this.availableSubAdminGroups.find(g => g.id === group.id) + return availableGroup?.name ?? group.name ?? group.id + }) .join(', ') }, @@ -442,7 +451,7 @@ export default { }, canEdit() { - return getCurrentUser().uid !== this.user.id || this.settings.isAdmin + return getCurrentUser().uid !== this.user.id || this.settings.isAdmin || this.settings.isDelegatedAdmin }, userQuota() { @@ -514,7 +523,6 @@ export default { return this.languages[0].languages.concat(this.languages[1].languages) }, }, - async beforeMount() { if (this.user.manager) { await this.initManager(this.user.manager) @@ -522,8 +530,9 @@ export default { }, methods: { - wipeUserDevices() { + async wipeUserDevices() { const userid = this.user.id + await confirmPassword() OC.dialogs.confirmDestructive( t('settings', 'In case of lost device or exiting the organization, this can remotely wipe the Nextcloud data from all devices associated with {userid}. Only works if the devices are connected to the internet.', { userid }), t('settings', 'Remote wipe of devices'), @@ -565,6 +574,66 @@ export default { this.loadingPossibleManagers = false }, + async loadGroupsDetails() { + this.loading.groups = true + this.loading.groupsDetails = true + try { + const groups = await loadUserGroups({ userId: this.user.id }) + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + this.selectedGroups = this.selectedGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup) + } catch (error) { + logger.error(t('settings', 'Failed to load groups with details'), { error }) + } + this.loading.groups = false + this.loading.groupsDetails = false + }, + + async loadSubAdminGroupsDetails() { + this.loading.subadmins = true + this.loading.subAdminGroupsDetails = true + try { + const groups = await loadUserSubAdminGroups({ userId: this.user.id }) + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + this.selectedSubAdminGroups = this.selectedSubAdminGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup) + } catch (error) { + logger.error(t('settings', 'Failed to load sub admin groups with details'), { error }) + } + this.loading.subadmins = false + this.loading.subAdminGroupsDetails = false + }, + + async searchGroups(query, toggleLoading) { + if (query === '') { + return // Prevent unexpected search behaviour e.g. on option:created + } + if (this.promise) { + this.promise.cancel() + } + toggleLoading(true) + try { + this.promise = await searchGroups({ + search: query, + offset: 0, + limit: 25, + }) + const groups = await this.promise + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + } catch (error) { + logger.error(t('settings', 'Failed to search groups'), { error }) + } + this.promise = null + toggleLoading(false) + }, + async searchUserManager(query) { await this.$store.dispatch('searchUsers', { offset: 0, limit: 10, search: query }).then(response => { const users = response?.data ? this.filterManagers(Object.values(response?.data.ocs.data.users)) : [] @@ -574,11 +643,12 @@ export default { }) }, - async updateUserManager(manager) { - if (manager === null) { - this.currentManager = '' - } + async updateUserManager() { this.loading.manager = true + + // Store the current manager before making changes + const previousManager = this.user.manager + try { await this.$store.dispatch('setUserData', { userid: this.user.id, @@ -587,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'), @@ -637,68 +711,70 @@ export default { /** * Set user displayName - * - * @param {string} displayName The display name */ - updateDisplayName() { + async updateDisplayName() { this.loading.displayName = true - this.$store.dispatch('setUserData', { - userid: this.user.id, - key: 'displayname', - value: this.editedDisplayName, - }).then(() => { - this.loading.displayName = false + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'displayname', + value: this.editedDisplayName, + }) + if (this.editedDisplayName === this.user.displayname) { - showSuccess(t('setting', 'Display name was successfully changed')) + showSuccess(t('settings', 'Display name was successfully changed')) } - }) + } finally { + this.loading.displayName = false + } }, /** * Set user password - * - * @param {string} password The email address */ - updatePassword() { + async updatePassword() { this.loading.password = true if (this.editedPassword.length === 0) { - showError(t('setting', "Password can't be empty")) + showError(t('settings', "Password can't be empty")) this.loading.password = false } else { - this.$store.dispatch('setUserData', { - userid: this.user.id, - key: 'password', - value: this.editedPassword, - }).then(() => { - this.loading.password = false + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'password', + value: this.editedPassword, + }) this.editedPassword = '' - showSuccess(t('setting', 'Password was successfully changed')) - }) + showSuccess(t('settings', 'Password was successfully changed')) + } finally { + this.loading.password = false + } } }, /** * Set user mailAddress - * - * @param {string} mailAddress The email address */ - updateEmail() { + async updateEmail() { this.loading.mailAddress = true if (this.editedMail === '') { - showError(t('setting', "Email can't be empty")) + showError(t('settings', "Email can't be empty")) this.loading.mailAddress = false this.editedMail = this.user.email } else { - this.$store.dispatch('setUserData', { - userid: this.user.id, - key: 'email', - value: this.editedMail, - }).then(() => { - this.loading.mailAddress = false + try { + await this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'email', + value: this.editedMail, + }) + if (this.editedMail === this.user.email) { - showSuccess(t('setting', 'Email was successfully changed')) + showSuccess(t('settings', 'Email was successfully changed')) } - }) + } finally { + this.loading.mailAddress = false + } } }, @@ -708,17 +784,16 @@ export default { * @param {string} gid Group id */ async createGroup({ name: gid }) { - this.loading = { groups: true, subadmins: true } + this.loading.groups = true try { await this.$store.dispatch('addGroup', gid) const userid = this.user.id await this.$store.dispatch('addUserGroup', { userid, gid }) + this.userGroups.push({ id: gid, name: gid }) } catch (error) { - console.error(error) - } finally { - this.loading = { groups: false, subadmins: false } + logger.error(t('settings', 'Failed to create group'), { error }) } - return this.$store.getters.getGroups[this.groups.length] + this.loading.groups = false }, /** @@ -732,19 +807,19 @@ export default { // Ignore return } - this.loading.groups = true const userid = this.user.id const gid = group.id if (group.canAdd === false) { - return false + return } + this.loading.groups = true try { await this.$store.dispatch('addUserGroup', { userid, gid }) + this.userGroups.push(group) } catch (error) { console.error(error) - } finally { - this.loading.groups = false } + this.loading.groups = false }, /** @@ -764,6 +839,7 @@ export default { userid, gid, }) + this.userGroups = this.userGroups.filter(group => group.id !== gid) this.loading.groups = false // remove user from current list if current list is the removed group if (this.$route.params.selectedGroup === gid) { @@ -788,10 +864,11 @@ export default { userid, gid, }) - this.loading.subadmins = false + this.userSubAdminGroups.push(group) } catch (error) { console.error(error) } + this.loading.subadmins = false }, /** @@ -809,6 +886,7 @@ export default { userid, gid, }) + this.userSubAdminGroups = this.userSubAdminGroups.filter(group => group.id !== gid) } catch (error) { console.error(error) } finally { @@ -898,7 +976,7 @@ export default { sendWelcomeMail() { this.loading.all = true this.$store.dispatch('sendWelcomeMail', this.user.id) - .then(() => showSuccess(t('setting', 'Welcome mail sent!'), { timeout: 2000 })) + .then(() => showSuccess(t('settings', 'Welcome mail sent!'), { timeout: 2000 })) .finally(() => { this.loading.all = false }) @@ -909,6 +987,8 @@ export default { if (this.editing) { await this.$nextTick() this.$refs.displayNameField?.$refs?.inputField?.$refs?.input?.focus() + this.loadGroupsDetails() + this.loadSubAdminGroupsDetails() } if (this.editedDisplayName !== this.user.displayname) { this.editedDisplayName = this.user.displayname @@ -921,10 +1001,10 @@ export default { </script> <style lang="scss" scoped> -@import './shared/styles.scss'; +@use './shared/styles'; .user-list__row { - @include row; + @include styles.row; &:hover { background-color: var(--color-background-hover); @@ -941,7 +1021,7 @@ export default { } .row { - @include cell; + @include styles.cell; &__cell { border-bottom: 1px solid var(--color-border); diff --git a/apps/settings/src/components/Users/UserRowActions.vue b/apps/settings/src/components/Users/UserRowActions.vue index a01bb868c7a..efd70d879a7 100644 --- a/apps/settings/src/components/Users/UserRowActions.vue +++ b/apps/settings/src/components/Users/UserRowActions.vue @@ -1,24 +1,6 @@ <!-- - - @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de> - - - - @author Christopher Ng <chrng8@gmail.com> - - @author Ferdinand Thiessen <opensource@fthiessen.de> - - - - @license AGPL-3.0-or-later - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> @@ -33,13 +15,17 @@ <NcIconSvgWrapper :key="editSvg" :svg="editSvg" aria-hidden="true" /> </template> </NcActionButton> - <NcActionButton v-for="({ action, icon, text }, index) in actions" + <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> @@ -47,17 +33,19 @@ <script lang="ts"> 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, + enabled?: (user: Record<string, unknown>) => boolean, icon: string, - text: string + text: string, } export default defineComponent({ @@ -105,12 +93,21 @@ export default defineComponent({ /** * Current MDI logo to show for edit toggle */ - editSvg() { + 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 */ diff --git a/apps/settings/src/components/Users/UserSettingsDialog.vue b/apps/settings/src/components/Users/UserSettingsDialog.vue index d8db67514c4..94c77d320dd 100644 --- a/apps/settings/src/components/Users/UserSettingsDialog.vue +++ b/apps/settings/src/components/Users/UserSettingsDialog.vue @@ -1,23 +1,6 @@ <!-- - - @copyright 2023 Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license AGPL-3.0-or-later - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> @@ -42,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') }} @@ -55,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" @@ -87,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> @@ -104,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', @@ -181,6 +174,15 @@ export default { }, }, + showFirstLogin: { + get() { + return this.showConfig.showFirstLogin + }, + set(status) { + this.setShowConfig('showFirstLogin', status) + }, + }, + showLastLogin: { get() { return this.showConfig.showLastLogin @@ -246,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 } @@ -256,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 }) }, @@ -271,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 } }, /** @@ -308,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 4ccc3fc60d6..20dc70ef830 100644 --- a/apps/settings/src/components/Users/VirtualList.vue +++ b/apps/settings/src/components/Users/VirtualList.vue @@ -1,23 +1,6 @@ <!-- - - @copyright 2023 Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license AGPL-3.0-or-later - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> @@ -52,7 +35,7 @@ <script lang="ts"> import Vue from 'vue' import { vElementVisibility } from '@vueuse/components' -import { debounce } from 'debounce' +import debounce from 'debounce' import logger from '../../logger.ts' @@ -174,6 +157,7 @@ export default Vue.extend({ display: block; overflow: auto; height: 100%; + will-change: scroll-position; &__header, &__footer { @@ -188,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 a2ddcd8c8be..4dfdd58af6d 100644 --- a/apps/settings/src/components/Users/shared/styles.scss +++ b/apps/settings/src/components/Users/shared/styles.scss @@ -1,23 +1,6 @@ /** - * @copyright 2023 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ @mixin row { @@ -57,15 +40,19 @@ } &--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-inline-start: calc(var(--default-grid-baseline) * 3); + } + &--avatar { min-width: var(--avatar-cell-width); width: var(--avatar-cell-width); @@ -105,7 +92,7 @@ &--actions { position: sticky; - right: 0; + inset-inline-end: 0; z-index: var(--sticky-column-z-index); display: flex; flex-direction: row; @@ -113,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); } } |