aboutsummaryrefslogtreecommitdiffstats
path: root/apps/settings/src/components/Users/UserRow.vue
diff options
context:
space:
mode:
Diffstat (limited to 'apps/settings/src/components/Users/UserRow.vue')
-rw-r--r--apps/settings/src/components/Users/UserRow.vue350
1 files changed, 215 insertions, 135 deletions
diff --git a/apps/settings/src/components/Users/UserRow.vue b/apps/settings/src/components/Users/UserRow.vue
index a27b41e11a6..43668725972 100644
--- a/apps/settings/src/components/Users/UserRow.vue
+++ b/apps/settings/src/components/Users/UserRow.vue
@@ -1,34 +1,14 @@
<!--
- - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- - @author Gary Kim <gary@garykim.dev>
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<tr class="user-list__row"
: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 user …')"
+ :name="t('settings', 'Loading account …')"
:size="32" />
<NcAvatar v-else-if="visible"
disable-menu
@@ -54,13 +34,14 @@
spellcheck="false"
@trailing-button-click="updateDisplayName" />
</template>
- <template v-else>
- <strong v-if="!isObfuscated"
- :title="user.displayname?.length > 20 ? user.displayname : null">
- {{ user.displayname }}
- </strong>
- <span class="row__subtitle">{{ user.id }}</span>
- </template>
+ <strong v-else-if="!isObfuscated"
+ :title="user.displayname?.length > 20 ? user.displayname : null">
+ {{ user.displayname }}
+ </strong>
+ </td>
+
+ <td class="row__cell row__cell--username" data-cy-user-list-cell-username>
+ <span class="row__subtitle">{{ user.id }}</span>
</td>
<td data-cy-user-list-cell-password
@@ -87,7 +68,7 @@
@trailing-button-click="updatePassword" />
</template>
<span v-else-if="isObfuscated">
- {{ t('settings', 'You do not have permissions to see the details of this user') }}
+ {{ t('settings', 'You do not have permissions to see the details of this account') }}
</span>
</td>
@@ -119,23 +100,24 @@
<template v-if="editing">
<label class="hidden-visually"
:for="'groups' + uniqueId">
- {{ t('settings', 'Add user to group') }}
+ {{ t('settings', 'Add account to group') }}
</label>
<NcSelect data-cy-user-list-input-groups
:data-loading="loading.groups || undefined"
:input-id="'groups' + uniqueId"
:close-on-select="false"
- :disabled="isLoadingField"
+ :disabled="isLoadingField || loading.groupsDetails"
:loading="loading.groups"
:multiple="true"
:append-to-body="false"
:options="availableGroups"
- :placeholder="t('settings', 'Add user to group')"
- :taggable="settings.isAdmin"
+ :placeholder="t('settings', 'Add account to group')"
+ :taggable="settings.isAdmin || settings.isDelegatedAdmin"
:value="userGroups"
label="name"
:no-wrap="true"
- :create-option="(value) => ({ name: value, isCreating: true })"
+ :create-option="(value) => ({ id: value, name: value, isCreating: true })"
+ @search="searchGroups"
@option:created="createGroup"
@option:selected="options => addUserGroup(options.at(-1))"
@option:deselected="removeUserGroup" />
@@ -146,33 +128,34 @@
</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 user as admin for') }}
+ {{ 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"
+ :disabled="isLoadingField || loading.subAdminGroupsDetails"
:loading="loading.subadmins"
label="name"
:append-to-body="false"
:multiple="true"
:no-wrap="true"
- :options="subAdminsGroups"
- :placeholder="t('settings', 'Set user as admin for')"
- :value="userSubAdminsGroups"
+ :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="userSubAdminsGroupsLabels?.length > 40 ? userSubAdminsGroupsLabels : null">
- {{ userSubAdminsGroupsLabels }}
+ :title="userSubAdminGroupsLabels?.length > 40 ? userSubAdminGroupsLabels : null">
+ {{ userSubAdminGroupsLabels }}
</span>
</td>
@@ -180,7 +163,7 @@
<template v-if="editing">
<label class="hidden-visually"
:for="'quota' + uniqueId">
- {{ t('settings', 'Select user quota') }}
+ {{ t('settings', 'Select account quota') }}
</label>
<NcSelect v-model="editedUserQuota"
:close-on-select="true"
@@ -193,7 +176,7 @@
:clearable="false"
:input-id="'quota' + uniqueId"
:options="quotaOptions"
- :placeholder="t('settings', 'Select user quota')"
+ :placeholder="t('settings', 'Select account quota')"
:taggable="true"
@option:selected="setUserQuota" />
</template>
@@ -248,6 +231,12 @@
</template>
</td>
+ <td v-if="showConfig.showFirstLogin"
+ class="row__cell"
+ data-cy-user-list-cell-first-login>
+ <span v-if="!isObfuscated">{{ userFirstLogin }}</span>
+ </td>
+
<td v-if="showConfig.showLastLogin"
:title="userLastLoginTooltip"
class="row__cell"
@@ -266,16 +255,17 @@
data-cy-user-list-input-manager
:data-loading="loading.manager || undefined"
:input-id="'manager' + uniqueId"
- :close-on-select="true"
:disabled="isLoadingField"
- :append-to-body="false"
:loading="loadingPossibleManagers || loading.manager"
- label="displayname"
:options="possibleManagers"
:placeholder="managerLabel"
+ label="displayname"
+ :filterable="false"
+ :internal-search="false"
+ :clearable="true"
@open="searchInitialUserManager"
@search="searchUserManager"
- @option:selected="updateUserManager" />
+ @update:model-value="updateUserManager" />
</template>
<span v-else-if="!isObfuscated">
{{ user.manager }}
@@ -297,17 +287,20 @@
import { formatFileSize, parseFileSize } from '@nextcloud/files'
import { getCurrentUser } from '@nextcloud/auth'
import { showSuccess, showError } from '@nextcloud/dialogs'
+import { confirmPassword } from '@nextcloud/password-confirmation'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
import UserRowActions from './UserRowActions.vue'
import UserRowMixin from '../../mixins/UserRowMixin.js'
-import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts';
+import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts'
+import { searchGroups, loadUserGroups, loadUserSubAdminGroups } from '../../service/groups.ts'
+import logger from '../../logger.ts'
export default {
name: 'UserRow',
@@ -342,14 +335,6 @@ export default {
type: Boolean,
required: true,
},
- groups: {
- type: Array,
- default: () => [],
- },
- subAdminsGroups: {
- type: Array,
- required: true,
- },
quotaOptions: {
type: Array,
required: true,
@@ -382,6 +367,8 @@ export default {
password: false,
mailAddress: false,
groups: false,
+ groupsDetails: false,
+ subAdminGroupsDetails: false,
subadmins: false,
quota: false,
delete: false,
@@ -393,13 +380,15 @@ export default {
editedDisplayName: this.user.displayname,
editedPassword: '',
editedMail: this.user.email ?? '',
+ // Cancelable promise for search groups request
+ promise: null,
}
},
computed: {
managerLabel() {
- // TRANSLATORS This string describes a manager in the context of an organization
- return t('settings', 'Set user manager')
+ // TRANSLATORS This string describes a person's manager in the context of an organization
+ return t('settings', 'Set line manager')
},
isObfuscated() {
@@ -422,15 +411,35 @@ export default {
return encodeURIComponent(this.user.id + this.rand)
},
+ availableGroups() {
+ const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin)
+ ? this.$store.getters.getSortedGroups
+ : this.$store.getters.getSubAdminGroups
+
+ return groups.filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled')
+ },
+
+ availableSubAdminGroups() {
+ return this.availableGroups.filter(group => group.id !== 'admin')
+ },
+
userGroupsLabels() {
return this.userGroups
- .map(group => group.name)
+ .map(group => {
+ // Try to match with more extensive group data
+ const availableGroup = this.availableGroups.find(g => g.id === group.id)
+ return availableGroup?.name ?? group.name ?? group.id
+ })
.join(', ')
},
- userSubAdminsGroupsLabels() {
- return this.userSubAdminsGroups
- .map(group => group.name)
+ userSubAdminGroupsLabels() {
+ return this.userSubAdminGroups
+ .map(group => {
+ // Try to match with more extensive group data
+ const availableGroup = this.availableSubAdminGroups.find(g => g.id === group.id)
+ return availableGroup?.name ?? group.name ?? group.id
+ })
.join(', ')
},
@@ -442,7 +451,7 @@ export default {
},
canEdit() {
- return getCurrentUser().uid !== this.user.id || this.settings.isAdmin
+ return getCurrentUser().uid !== this.user.id || this.settings.isAdmin || this.settings.isDelegatedAdmin
},
userQuota() {
@@ -469,17 +478,17 @@ export default {
const actions = [
{
icon: 'icon-delete',
- text: t('settings', 'Delete user'),
+ text: t('settings', 'Delete account'),
action: this.deleteUser,
},
{
icon: 'icon-delete',
- text: t('settings', 'Wipe all devices'),
+ 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 user') : t('settings', 'Enable user'),
+ text: this.user.enabled ? t('settings', 'Disable account') : t('settings', 'Enable account'),
action: this.enableDisableUser,
},
]
@@ -514,7 +523,6 @@ export default {
return this.languages[0].languages.concat(this.languages[1].languages)
},
},
-
async beforeMount() {
if (this.user.manager) {
await this.initManager(this.user.manager)
@@ -522,8 +530,9 @@ export default {
},
methods: {
- wipeUserDevices() {
+ async wipeUserDevices() {
const userid = this.user.id
+ await confirmPassword()
OC.dialogs.confirmDestructive(
t('settings', 'In case of lost device or exiting the organization, this can remotely wipe the Nextcloud data from all devices associated with {userid}. Only works if the devices are connected to the internet.', { userid }),
t('settings', 'Remote wipe of devices'),
@@ -565,6 +574,66 @@ export default {
this.loadingPossibleManagers = false
},
+ async loadGroupsDetails() {
+ this.loading.groups = true
+ this.loading.groupsDetails = true
+ try {
+ const groups = await loadUserGroups({ userId: this.user.id })
+ // Populate store from server request
+ for (const group of groups) {
+ this.$store.commit('addGroup', group)
+ }
+ this.selectedGroups = this.selectedGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup)
+ } catch (error) {
+ logger.error(t('settings', 'Failed to load groups with details'), { error })
+ }
+ this.loading.groups = false
+ this.loading.groupsDetails = false
+ },
+
+ async loadSubAdminGroupsDetails() {
+ this.loading.subadmins = true
+ this.loading.subAdminGroupsDetails = true
+ try {
+ const groups = await loadUserSubAdminGroups({ userId: this.user.id })
+ // Populate store from server request
+ for (const group of groups) {
+ this.$store.commit('addGroup', group)
+ }
+ this.selectedSubAdminGroups = this.selectedSubAdminGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup)
+ } catch (error) {
+ logger.error(t('settings', 'Failed to load sub admin groups with details'), { error })
+ }
+ this.loading.subadmins = false
+ this.loading.subAdminGroupsDetails = false
+ },
+
+ async searchGroups(query, toggleLoading) {
+ if (query === '') {
+ return // Prevent unexpected search behaviour e.g. on option:created
+ }
+ if (this.promise) {
+ this.promise.cancel()
+ }
+ toggleLoading(true)
+ try {
+ this.promise = await searchGroups({
+ search: query,
+ offset: 0,
+ limit: 25,
+ })
+ const groups = await this.promise
+ // Populate store from server request
+ for (const group of groups) {
+ this.$store.commit('addGroup', group)
+ }
+ } catch (error) {
+ logger.error(t('settings', 'Failed to search groups'), { error })
+ }
+ this.promise = null
+ toggleLoading(false)
+ },
+
async searchUserManager(query) {
await this.$store.dispatch('searchUsers', { offset: 0, limit: 10, search: query }).then(response => {
const users = response?.data ? this.filterManagers(Object.values(response?.data.ocs.data.users)) : []
@@ -574,11 +643,12 @@ export default {
})
},
- async updateUserManager(manager) {
- if (manager === null) {
- this.currentManager = ''
- }
+ async updateUserManager() {
this.loading.manager = true
+
+ // Store the current manager before making changes
+ const previousManager = this.user.manager
+
try {
await this.$store.dispatch('setUserData', {
userid: this.user.id,
@@ -586,16 +656,20 @@ export default {
value: this.currentManager ? this.currentManager.id : '',
})
} catch (error) {
- // TRANSLATORS This string describes a manager in the context of an organization
- showError(t('setting', 'Failed to update user manager'))
- console.error(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
}
},
- deleteUser() {
+ async deleteUser() {
const userid = this.user.id
+ await confirmPassword()
OC.dialogs.confirmDestructive(
t('settings', 'Fully delete {userid}\'s account including all their personal files, app data, etc.', { userid }),
t('settings', 'Account deletion'),
@@ -637,68 +711,70 @@ export default {
/**
* Set user displayName
- *
- * @param {string} displayName The display name
*/
- updateDisplayName() {
+ async updateDisplayName() {
this.loading.displayName = true
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'displayname',
- value: this.editedDisplayName,
- }).then(() => {
- this.loading.displayName = false
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'displayname',
+ value: this.editedDisplayName,
+ })
+
if (this.editedDisplayName === this.user.displayname) {
- showSuccess(t('setting', 'Display name was successfully changed'))
+ showSuccess(t('settings', 'Display name was successfully changed'))
}
- })
+ } finally {
+ this.loading.displayName = false
+ }
},
/**
* Set user password
- *
- * @param {string} password The email address
*/
- updatePassword() {
+ async updatePassword() {
this.loading.password = true
if (this.editedPassword.length === 0) {
- showError(t('setting', "Password can't be empty"))
+ showError(t('settings', "Password can't be empty"))
this.loading.password = false
} else {
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'password',
- value: this.editedPassword,
- }).then(() => {
- this.loading.password = false
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'password',
+ value: this.editedPassword,
+ })
this.editedPassword = ''
- showSuccess(t('setting', 'Password was successfully changed'))
- })
+ showSuccess(t('settings', 'Password was successfully changed'))
+ } finally {
+ this.loading.password = false
+ }
}
},
/**
* Set user mailAddress
- *
- * @param {string} mailAddress The email address
*/
- updateEmail() {
+ async updateEmail() {
this.loading.mailAddress = true
if (this.editedMail === '') {
- showError(t('setting', "Email can't be empty"))
+ showError(t('settings', "Email can't be empty"))
this.loading.mailAddress = false
this.editedMail = this.user.email
} else {
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'email',
- value: this.editedMail,
- }).then(() => {
- this.loading.mailAddress = false
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'email',
+ value: this.editedMail,
+ })
+
if (this.editedMail === this.user.email) {
- showSuccess(t('setting', 'Email was successfully changed'))
+ showSuccess(t('settings', 'Email was successfully changed'))
}
- })
+ } finally {
+ this.loading.mailAddress = false
+ }
}
},
@@ -708,17 +784,16 @@ export default {
* @param {string} gid Group id
*/
async createGroup({ name: gid }) {
- this.loading = { groups: true, subadmins: true }
+ this.loading.groups = true
try {
await this.$store.dispatch('addGroup', gid)
const userid = this.user.id
await this.$store.dispatch('addUserGroup', { userid, gid })
+ this.userGroups.push({ id: gid, name: gid })
} catch (error) {
- console.error(error)
- } finally {
- this.loading = { groups: false, subadmins: false }
+ logger.error(t('settings', 'Failed to create group'), { error })
}
- return this.$store.getters.getGroups[this.groups.length]
+ this.loading.groups = false
},
/**
@@ -732,19 +807,19 @@ export default {
// Ignore
return
}
- this.loading.groups = true
const userid = this.user.id
const gid = group.id
if (group.canAdd === false) {
- return false
+ return
}
+ this.loading.groups = true
try {
await this.$store.dispatch('addUserGroup', { userid, gid })
+ this.userGroups.push(group)
} catch (error) {
console.error(error)
- } finally {
- this.loading.groups = false
}
+ this.loading.groups = false
},
/**
@@ -764,6 +839,7 @@ export default {
userid,
gid,
})
+ this.userGroups = this.userGroups.filter(group => group.id !== gid)
this.loading.groups = false
// remove user from current list if current list is the removed group
if (this.$route.params.selectedGroup === gid) {
@@ -788,10 +864,11 @@ export default {
userid,
gid,
})
- this.loading.subadmins = false
+ this.userSubAdminGroups.push(group)
} catch (error) {
console.error(error)
}
+ this.loading.subadmins = false
},
/**
@@ -809,6 +886,7 @@ export default {
userid,
gid,
})
+ this.userSubAdminGroups = this.userSubAdminGroups.filter(group => group.id !== gid)
} catch (error) {
console.error(error)
} finally {
@@ -898,7 +976,7 @@ export default {
sendWelcomeMail() {
this.loading.all = true
this.$store.dispatch('sendWelcomeMail', this.user.id)
- .then(() => showSuccess(t('setting', 'Welcome mail sent!'), { timeout: 2000 }))
+ .then(() => showSuccess(t('settings', 'Welcome mail sent!'), { timeout: 2000 }))
.finally(() => {
this.loading.all = false
})
@@ -909,6 +987,8 @@ export default {
if (this.editing) {
await this.$nextTick()
this.$refs.displayNameField?.$refs?.inputField?.$refs?.input?.focus()
+ this.loadGroupsDetails()
+ this.loadSubAdminGroupsDetails()
}
if (this.editedDisplayName !== this.user.displayname) {
this.editedDisplayName = this.user.displayname
@@ -921,10 +1001,10 @@ export default {
</script>
<style lang="scss" scoped>
-@import './shared/styles.scss';
+@use './shared/styles';
.user-list__row {
- @include row;
+ @include styles.row;
&:hover {
background-color: var(--color-background-hover);
@@ -941,7 +1021,7 @@ export default {
}
.row {
- @include cell;
+ @include styles.cell;
&__cell {
border-bottom: 1px solid var(--color-border);