aboutsummaryrefslogtreecommitdiffstats
path: root/apps/settings/src/components/Users
diff options
context:
space:
mode:
Diffstat (limited to 'apps/settings/src/components/Users')
-rw-r--r--apps/settings/src/components/Users/NewUserDialog.vue436
-rw-r--r--apps/settings/src/components/Users/UserListFooter.vue112
-rw-r--r--apps/settings/src/components/Users/UserListHeader.vue152
-rw-r--r--apps/settings/src/components/Users/UserRow.vue1049
-rw-r--r--apps/settings/src/components/Users/UserRowActions.vue119
-rw-r--r--apps/settings/src/components/Users/UserSettingsDialog.vue337
-rw-r--r--apps/settings/src/components/Users/VirtualList.vue184
-rw-r--r--apps/settings/src/components/Users/shared/styles.scss110
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);
+ }
+}