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