feat(settings): replace inline user editing with edit dialog

Replace inline row editing in user management with a modal dialog.
Extract shared form fields into UserFormFields with sub-components
(Groups, Quota, Language, Manager) using a single formData prop.
Normalize newUser shape to API-aligned field names.

Refs: #40903
-e
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
This commit is contained in:
Peter Ringelmann 2026-04-01 12:27:29 +02:00
parent f96730587f
commit 5eb8ef6f18
11 changed files with 913 additions and 1042 deletions

View file

@ -13,6 +13,12 @@
@reset="resetForm"
@closing="closeDialog" />
<EditUserDialog
v-if="editingUser"
:user="editingUser"
:quota-options="quotaOptions"
@closing="editingUser = null" />
<NcEmptyContent
v-if="filteredUsers.length === 0"
class="empty"
@ -40,6 +46,7 @@
quotaOptions,
languages,
externalActions,
onEditUser: openEditDialog,
}"
@scroll-end="handleScrollEnd">
<template #before>
@ -69,6 +76,7 @@ import { Fragment } from 'vue-frag'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import EditUserDialog from './Users/EditUserDialog.vue'
import NewUserDialog from './Users/NewUserDialog.vue'
import UserListFooter from './Users/UserListFooter.vue'
import UserListHeader from './Users/UserListHeader.vue'
@ -78,13 +86,13 @@ import logger from '../logger.ts'
import { defaultQuota, unlimitedQuota } from '../utils/userUtils.ts'
const newUser = Object.freeze({
id: '',
username: '',
displayName: '',
password: '',
mailAddress: '',
email: '',
groups: [],
manager: '',
subAdminsGroups: [],
subadminGroups: [],
quota: defaultQuota,
language: {
code: 'en',
@ -96,6 +104,7 @@ export default {
name: 'UserList',
components: {
EditUserDialog,
Fragment,
NcEmptyContent,
NcIconSvgWrapper,
@ -137,6 +146,7 @@ export default {
},
newUser: { ...newUser },
editingUser: null,
isInitialLoad: true,
}
},
@ -267,6 +277,10 @@ export default {
},
methods: {
openEditDialog(user) {
this.editingUser = user
},
async handleScrollEnd() {
await this.loadUsers()
},

View file

@ -0,0 +1,278 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog
class="edit-dialog"
size="small"
:name="t('settings', 'Edit account')"
out-transition
@closing="$emit('closing')">
<form
id="edit-user-form"
class="edit-dialog__form"
data-test="form"
:disabled="saving"
@submit.prevent="save">
<UserFormFields
:formData="editedUser"
:fieldConfig="fieldConfig"
:errors="fieldErrors"
:quotaOptions="quotaOptions" />
</form>
<template #actions>
<NcButton
class="edit-dialog__submit"
data-test="submit"
form="edit-user-form"
variant="primary"
type="submit"
:disabled="saving">
{{ saving ? t('settings', 'Saving\u00A0…') : t('settings', 'Save') }}
</NcButton>
</template>
</NcDialog>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { formatFileSize } from '@nextcloud/files'
import { confirmPassword } from '@nextcloud/password-confirmation'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import UserFormFields from './UserFormFields.vue'
import logger from '../../logger.ts'
import { unlimitedQuota } from '../../utils/userUtils.ts'
/**
* Maps a user store object to the flat, API-aligned shape used by the form.
* Keeps a clean separation between the store model (e.g. `user.displayname`,
* `user.quota.quota`) and the form model (e.g. `displayName`, `quota`).
*
* @param {object} user The user store object
* @param {Array} allGroups All available groups from the store
* @param {Array} quotaOptions Quota preset options
* @param {object} serverLanguages Server language configuration
* @return {object} Form-ready data object
*/
function userToFormData(user, allGroups, quotaOptions, serverLanguages) {
const groups = user.groups
.map((id) => allGroups.find((g) => g.id === id))
.filter(Boolean)
const subadminGroups = (user.subadmin ?? [])
.map((id) => allGroups.find((g) => g.id === id))
.filter(Boolean)
let quota
if (user.quota?.quota >= 0) {
const label = formatFileSize(user.quota.quota)
quota = quotaOptions.find((q) => q.id === label) ?? { id: label, label }
} else if (user.quota?.quota === 'default') {
quota = quotaOptions[0]
} else {
quota = unlimitedQuota
}
return {
username: user.id,
displayName: user.displayname ?? '',
password: '',
email: user.email ?? '',
groups,
subadminGroups,
quota,
language: resolveLanguage(user, serverLanguages),
manager: user.manager ?? '',
}
}
/**
* Resolves the user's language code to a { code, name } object.
*
* @param {object} user The user store object
* @param {object} serverLanguages Server language configuration
* @return {object} Language object with code and name
*/
function resolveLanguage(user, serverLanguages) {
if (!user.language || user.language === '') {
return { code: '', name: '' }
}
// Look up the display name from the server languages list
const allLangs = [
...(serverLanguages?.commonLanguages ?? []),
...(serverLanguages?.otherLanguages ?? []),
]
const match = allLangs.find((lang) => lang.code === user.language)
if (match) {
return match
}
return { code: user.language, name: user.language }
}
/**
* Generic shallow diff between initial and current form data.
* Returns only fields that changed, with API-ready values.
*
* @param {object} initial Snapshot of form data at mount time
* @param {object} current Current form data state
* @return {object} Changed fields with API-ready values
*/
function diffPayload(initial, current) {
const payload = {}
if (current.displayName !== initial.displayName) {
payload.displayName = current.displayName
}
if (current.password !== '') {
payload.password = current.password
}
if (current.email !== initial.email) {
payload.email = current.email
}
if (current.quota.id !== initial.quota.id) {
payload.quota = current.quota.id
}
if (current.language.code !== initial.language.code) {
payload.language = current.language.code
}
const currentManagerId = typeof current.manager === 'object' ? (current.manager.id ?? '') : current.manager
const initialManagerId = typeof initial.manager === 'object' ? (initial.manager.id ?? '') : initial.manager
if (currentManagerId !== initialManagerId) {
payload.manager = currentManagerId
}
const currentGroupIds = current.groups.map((g) => g.id).sort()
const initialGroupIds = initial.groups.map((g) => g.id).sort()
if (JSON.stringify(currentGroupIds) !== JSON.stringify(initialGroupIds)) {
payload.groups = currentGroupIds
}
const currentSubadminIds = current.subadminGroups.map((g) => g.id).sort()
const initialSubadminIds = initial.subadminGroups.map((g) => g.id).sort()
if (JSON.stringify(currentSubadminIds) !== JSON.stringify(initialSubadminIds)) {
payload.subadminGroups = currentSubadminIds
}
return payload
}
export default {
name: 'EditUserDialog',
components: {
NcButton,
NcDialog,
UserFormFields,
},
props: {
user: {
type: Object,
required: true,
},
quotaOptions: {
type: Array,
required: true,
},
},
emits: ['closing'],
data() {
const allGroups = this.$store.getters.getGroups
const serverLanguages = this.$store.getters.getServerData.languages
const formData = userToFormData(this.user, allGroups, this.quotaOptions, serverLanguages)
return {
/** Snapshot of initial state for diffing */
initialData: structuredClone(formData),
/** Mutable form state */
editedUser: formData,
saving: false,
fieldErrors: {},
}
},
computed: {
settings() {
return this.$store.getters.getServerData
},
/**
* Field configuration for the edit dialog.
* Username is shown but read-only; password visibility depends
* on the backend's capabilities and admin permissions.
*/
fieldConfig() {
return {
username: {
show: true,
label: t('settings', 'Account name'),
readonly: true,
},
password: {
show: this.settings.canChangePassword && this.user.backendCapabilities.setPassword,
label: t('settings', 'New password'),
},
}
},
},
methods: {
async save() {
this.fieldErrors = {}
const payload = diffPayload(this.initialData, this.editedUser)
if (Object.keys(payload).length === 0) {
this.$emit('closing')
return
}
this.saving = true
try {
await confirmPassword()
await this.$store.dispatch('editUserMultiField', {
userid: this.user.id,
payload,
})
showSuccess(t('settings', 'Account updated'))
this.$emit('closing')
} catch (error) {
const errors = error.response?.data?.ocs?.data?.errors
if (errors && typeof errors === 'object') {
this.fieldErrors = errors
} else {
logger.error('Failed to update account', { error })
showError(t('settings', 'Failed to update account'))
}
} finally {
this.saving = false
}
},
},
}
</script>
<style lang="scss" scoped>
.edit-dialog {
&__form {
padding: 0 8px;
}
&__submit {
margin-top: 4px;
margin-bottom: 8px;
}
:deep {
.dialog__actions {
margin: auto;
}
}
}
</style>

View file

@ -16,128 +16,11 @@
data-test="form"
:disabled="loading.all"
@submit.prevent="createUser">
<NcTextField
ref="username"
v-model="newUser.id"
class="dialog__item"
data-test="username"
:disabled="settings.newUserGenerateUserID"
:label="usernameLabel"
autocapitalize="none"
autocomplete="off"
spellcheck="false"
pattern="[a-zA-Z0-9 _\.@\-']+"
required />
<NcTextField
v-model="newUser.displayName"
class="dialog__item"
data-test="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"
v-model="newUser.password"
class="dialog__item"
data-test="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
v-model="newUser.mailAddress"
class="dialog__item"
data-test="email"
type="email"
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"
:model-value="newUser.groups"
label="name"
keep-open
: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:deselected="removeGroup"
@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="availableSubAdminGroups"
keep-open
: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" :class="[{ '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>
<UserFormFields
ref="fields"
:formData="newUser"
:fieldConfig="fieldConfig"
:quotaOptions="quotaOptions" />
</form>
<template #actions>
@ -154,14 +37,9 @@
</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 logger from '../../logger.ts'
import { searchGroups } from '../../service/groups.ts'
import UserFormFields from './UserFormFields.vue'
export default {
name: 'NewUserDialog',
@ -169,9 +47,7 @@ export default {
components: {
NcButton,
NcDialog,
NcPasswordField,
NcSelect,
NcTextField,
UserFormFields,
},
props: {
@ -191,23 +67,9 @@ export default {
},
},
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,
}
},
emits: ['closing', 'reset'],
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
settings() {
return this.$store.getters.getServerData
},
@ -219,44 +81,41 @@ export default {
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')
},
availableSubAdminGroups() {
return this.availableGroups.filter((group) => group.id !== 'admin')
},
languages() {
return [
{
name: t('settings', 'Common languages'),
languages: this.settings.languages.commonLanguages,
/**
* Reactive field configuration passed to UserFormFields.
* Controls visibility, labels, and required state for each field
* based on the current form values and server settings.
*/
fieldConfig() {
return {
username: {
show: true,
label: this.usernameLabel,
disabled: this.settings.newUserGenerateUserID,
required: true,
},
...this.settings.languages.commonLanguages,
{
name: t('settings', 'Other languages'),
languages: this.settings.languages.otherLanguages,
},
...this.settings.languages.otherLanguages,
]
},
},
async beforeMount() {
await this.searchUserManager()
password: {
label: this.newUser.email === ''
? t('settings', 'Password (required)')
: t('settings', 'Password'),
required: this.newUser.email === '',
},
email: {
label: this.newUser.password === '' || this.settings.newUserRequireEmail
? t('settings', 'Email (required)')
: t('settings', 'Email'),
required: this.newUser.password === '' || this.settings.newUserRequireEmail,
},
showPasswordEmailHint: !this.settings.newUserRequireEmail,
}
},
},
mounted() {
this.$refs.username?.focus?.()
this.$refs.fields?.focusField('username')
},
methods: {
@ -264,151 +123,32 @@ export default {
this.loading.all = true
try {
await this.$store.dispatch('addUser', {
userid: this.newUser.id,
userid: this.newUser.username,
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),
email: this.newUser.email,
groups: this.newUser.groups.map(({ id }) => id),
subadmin: this.newUser.subadminGroups.map(({ id }) => id),
quota: this.newUser.quota.id,
language: this.newUser.language.code,
manager: this.newUser.manager.id,
})
this.$emit('reset')
this.$refs.username?.focus?.()
this.$refs.fields?.focusField('username')
this.$emit('closing')
} catch (error) {
this.loading.all = false
if (error.response && error.response.data && error.response.data.ocs && error.response.data.ocs.meta) {
if (error.response?.data?.ocs?.meta) {
const statuscode = error.response.data.ocs.meta.statuscode
if (statuscode === 102) {
// wrong username
this.$refs.username?.focus?.()
this.$refs.fields?.focusField('username')
} else if (statuscode === 107) {
// wrong password
this.$refs.password?.focus?.()
this.$refs.fields?.focusField('password')
}
}
}
},
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)
},
/**
* Remove user from group
*
* @param {object} group Group object
*/
removeGroup(group) {
if (group.canRemove === false) {
return
}
this.newUser.groups = this.newUser.groups.filter((g) => g.id !== group.id)
},
/**
* 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>
@ -416,38 +156,7 @@ export default {
<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 {

View file

@ -0,0 +1,214 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="user-form-fields">
<div
v-if="fieldConfig.username?.show && fieldConfig.username?.readonly"
class="user-form-fields__item user-form-fields__readonly"
data-test="username">
<label class="user-form-fields__readonly-label">{{ fieldConfig.username?.label }}</label>
<span class="user-form-fields__readonly-value">{{ formData.username }}</span>
</div>
<NcTextField
v-else-if="fieldConfig.username?.show"
ref="username"
v-model="formData.username"
class="user-form-fields__item"
data-test="username"
:disabled="fieldConfig.username?.disabled"
:label="fieldConfig.username?.label"
autocapitalize="none"
autocomplete="off"
spellcheck="false"
pattern="[a-zA-Z0-9 _\.@\-']+"
:required="fieldConfig.username?.required" />
<NcTextField
v-model="formData.displayName"
class="user-form-fields__item"
data-test="displayName"
:label="t('settings', 'Display name')"
:error="!!errors.displayName"
:helper-text="errors.displayName"
autocapitalize="none"
autocomplete="off"
spellcheck="false" />
<span
v-if="fieldConfig.showPasswordEmailHint"
id="password-email-hint"
class="user-form-fields__hint">
{{ t('settings', 'Either password or email is required') }}
</span>
<NcPasswordField
v-if="fieldConfig.password?.show !== false"
ref="password"
v-model="formData.password"
class="user-form-fields__item"
data-test="password"
:minlength="minPasswordLength"
:maxlength="469"
aria-describedby="password-email-hint"
:label="fieldConfig.password?.label"
:error="!!errors.password"
:helper-text="errors.password"
autocapitalize="none"
autocomplete="new-password"
spellcheck="false"
:required="fieldConfig.password?.required" />
<NcTextField
v-model="formData.email"
class="user-form-fields__item"
data-test="email"
type="email"
aria-describedby="password-email-hint"
:label="fieldConfig.email?.label || t('settings', 'Email')"
:error="!!errors.email"
:helper-text="errors.email"
autocapitalize="none"
autocomplete="off"
spellcheck="false"
:required="fieldConfig.email?.required" />
<UserFormGroups :formData="formData" />
<UserFormQuota :formData="formData" :quotaOptions="quotaOptions" />
<UserFormLanguage :formData="formData" />
<UserFormManager :formData="formData" />
</div>
</template>
<script>
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import UserFormGroups from './UserFormGroups.vue'
import UserFormLanguage from './UserFormLanguage.vue'
import UserFormManager from './UserFormManager.vue'
import UserFormQuota from './UserFormQuota.vue'
/**
* Shared form fields for creating and editing user accounts.
*
* Receives a single mutable `formData` object (owned by the parent dialog)
* and binds directly to its properties via v-model. Complex field logic
* (groups, quota, language, manager) is delegated to dedicated sub-components
* that also receive and mutate formData directly.
*
* Expected formData shape:
* { username, displayName, password, email, groups, subadminGroups, quota, language, manager }
*/
export default {
name: 'UserFormFields',
components: {
NcPasswordField,
NcTextField,
UserFormGroups,
UserFormLanguage,
UserFormManager,
UserFormQuota,
},
props: {
/** The mutable form data object owned by the parent dialog */
formData: {
type: Object,
required: true,
},
/** Quota preset options for the quota select */
quotaOptions: {
type: Array,
required: true,
},
/**
* Per-field configuration for visibility, labels, and required state.
* Only fields that differ from defaults need to be specified.
*
* Example: { username: { show: true, label: 'Account name', required: true },
* password: { show: true, label: 'Password', required: false },
* email: { label: 'Email (required)', required: true },
* showPasswordEmailHint: true }
*/
fieldConfig: {
type: Object,
default: () => ({}),
},
/** Per-field error messages from 422 validation (e.g. { email: 'Invalid' }) */
errors: {
type: Object,
default: () => ({}),
},
},
computed: {
minPasswordLength() {
return this.$store.getters.getPasswordPolicyMinLength
},
},
methods: {
focusField(name) {
this.$refs[name]?.focus?.()
},
},
}
</script>
<style lang="scss" scoped>
.user-form-fields {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px 0;
&__item {
width: 100%;
&:not(:focus):not(:active) {
border-color: var(--color-border-dark);
}
}
&__readonly {
padding: 4px 0;
}
&__readonly-label {
display: block;
font-size: 13px;
color: var(--color-text-maxcontrast);
margin-bottom: 2px;
}
&__readonly-value {
display: block;
font-weight: bold;
}
&__hint {
color: var(--color-text-maxcontrast);
margin-top: 8px;
align-self: flex-start;
}
// Reach into sub-component root elements to apply consistent sizing
:deep(.user-form__item) {
width: 100%;
}
:deep(.user-form__select) {
width: 100%;
}
:deep(.user-form__managers) {
margin-bottom: 12px;
}
}
</style>

View file

@ -0,0 +1,129 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div>
<div class="user-form__item">
<NcSelect
v-model="formData.groups"
class="user-form__select"
data-test="groups"
:input-label="groupsLabel"
:placeholder="t('settings', 'Set account groups')"
:disabled="creatingGroup"
:options="availableGroups"
label="name"
keep-open
: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" />
</div>
<div
v-if="settings.isAdmin || settings.isDelegatedAdmin"
class="user-form__item">
<NcSelect
v-model="formData.subadminGroups"
class="user-form__select"
:input-label="t('settings', 'Admin of the following groups')"
:placeholder="t('settings', 'Set account as admin for …')"
:disabled="creatingGroup"
:options="availableSubAdminGroups"
keep-open
:multiple="true"
label="name"
@search="searchGroups" />
</div>
</div>
</template>
<script>
import NcSelect from '@nextcloud/vue/components/NcSelect'
import logger from '../../logger.ts'
import { searchGroups } from '../../service/groups.ts'
export default {
name: 'UserFormGroups',
components: {
NcSelect,
},
props: {
formData: {
type: Object,
required: true,
},
},
data() {
return {
creatingGroup: false,
promise: null,
}
},
computed: {
settings() {
return this.$store.getters.getServerData
},
availableGroups() {
const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin)
? this.$store.getters.getSortedGroups
: this.$store.getters.getSubAdminGroups
return groups.filter(({ id }) => id !== '__nc_internal_recent' && id !== 'disabled')
},
availableSubAdminGroups() {
return this.availableGroups.filter(({ id }) => id !== 'admin')
},
groupsLabel() {
return !this.settings.isAdmin && !this.settings.isDelegatedAdmin
? t('settings', 'Member of the following groups (required)')
: t('settings', 'Member of the following groups')
},
},
methods: {
async searchGroups(query, toggleLoading) {
if (!this.settings.isAdmin && !this.settings.isDelegatedAdmin) {
return
}
if (this.promise) {
this.promise.cancel()
}
toggleLoading(true)
try {
this.promise = searchGroups({ search: query, offset: 0, limit: 25 })
const groups = await this.promise
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 createGroup({ name: gid }) {
this.creatingGroup = true
try {
await this.$store.dispatch('addGroup', gid)
this.formData.groups.push({ id: gid, name: gid })
} catch (error) {
logger.error(t('settings', 'Failed to create group'), { error })
}
this.creatingGroup = false
},
},
}
</script>

View file

@ -0,0 +1,65 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
v-if="showConfig.showLanguages"
class="user-form__item">
<NcSelect
v-model="formData.language"
class="user-form__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>
</template>
<script>
import NcSelect from '@nextcloud/vue/components/NcSelect'
export default {
name: 'UserFormLanguage',
components: {
NcSelect,
},
props: {
formData: {
type: Object,
required: true,
},
},
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
languages() {
const { commonLanguages, otherLanguages } = this.$store.getters.getServerData.languages
return [
{ name: t('settings', 'Common languages'), languages: commonLanguages },
...commonLanguages,
{ name: t('settings', 'Other languages'), languages: otherLanguages },
...otherLanguages,
]
},
},
methods: {
languageFilterBy(option, label, search) {
if (option.languages) {
return option.languages.some(({ name }) => name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
}
return (label || '').toLocaleLowerCase().includes(search.toLocaleLowerCase())
},
},
}
</script>

View file

@ -0,0 +1,71 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="user-form__item user-form__managers">
<NcSelect
v-model="formData.manager"
class="user-form__select"
:input-label="t('settings', 'Manager')"
:placeholder="t('settings', 'Set line manager')"
:options="possibleManagers"
:user-select="true"
label="displayname"
:clearable="true"
:loading="loading"
@open="onOpen"
@search="searchUserManager" />
</div>
</template>
<script>
import NcSelect from '@nextcloud/vue/components/NcSelect'
export default {
name: 'UserFormManager',
components: {
NcSelect,
},
props: {
formData: {
type: Object,
required: true,
},
},
data() {
return {
possibleManagers: [],
loading: false,
fetched: false,
}
},
methods: {
async onOpen() {
if (!this.fetched) {
this.loading = true
await this.searchUserManager()
this.loading = false
this.fetched = true
}
},
async searchUserManager(query) {
const response = await this.$store.dispatch('searchUsers', {
offset: 0,
limit: 10,
search: query,
})
const users = response?.data ? Object.values(response.data.ocs.data.users) : []
if (users.length > 0) {
this.possibleManagers = users
}
},
},
}
</script>

View file

@ -0,0 +1,53 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="user-form__item">
<NcSelect
v-model="formData.quota"
class="user-form__select"
:input-label="t('settings', 'Quota')"
:placeholder="t('settings', 'Set account quota')"
:options="quotaOptions"
:clearable="false"
:taggable="true"
:create-option="validateQuota" />
</div>
</template>
<script>
import { formatFileSize, parseFileSize } from '@nextcloud/files'
import NcSelect from '@nextcloud/vue/components/NcSelect'
export default {
name: 'UserFormQuota',
components: {
NcSelect,
},
props: {
formData: {
type: Object,
required: true,
},
quotaOptions: {
type: Array,
required: true,
},
},
methods: {
validateQuota(quota) {
const parsed = parseFileSize(quota, true)
if (parsed !== null && parsed >= 0) {
const label = formatFileSize(parsed)
return { id: label, label }
}
return this.quotaOptions[0]
},
},
}
</script>

View file

@ -20,26 +20,8 @@
</td>
<td class="row__cell row__cell--displayname" data-cy-user-list-cell-displayname>
<template v-if="editing && user.backendCapabilities.setDisplayName">
<NcTextField
ref="displayNameField"
v-model="editedDisplayName"
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="arrowEnd"
autocapitalize="off"
autocomplete="off"
spellcheck="false"
@trailing-button-click="updateDisplayName" />
</template>
<strong
v-else-if="!isObfuscated"
v-if="!isObfuscated"
:title="user.displayname?.length > 20 ? user.displayname : null">
{{ user.displayname }}
</strong>
@ -50,61 +32,16 @@
</td>
<td class="row__cell" data-cy-user-list-cell-email>
<template v-if="editing">
<NcTextField
v-model="editedMail"
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="arrowEnd"
autocapitalize="off"
autocomplete="email"
spellcheck="false"
type="email"
@trailing-button-click="updateEmail" />
</template>
<span
v-else-if="!isObfuscated"
v-if="!isObfuscated"
:title="user.email?.length > 20 ? user.email : null">
{{ user.email }}
</span>
</td>
<td class="row__cell row__cell--groups 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"
keep-open
: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"
:model-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"
v-if="!isObfuscated"
:title="userGroupsLabels?.length > 40 ? userGroupsLabels : null">
{{ userGroupsLabels }}
</span>
@ -114,60 +51,15 @@
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"
keep-open
: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')"
:model-value="userSubAdminGroups"
@search="searchGroups"
@option:deselected="removeUserSubAdmin"
@option:selected="options => addUserSubAdmin(options.at(-1))" />
</template>
<span
v-else-if="!isObfuscated"
v-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"
: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">
<template v-if="!isObfuscated">
<span :id="'quota-progress' + uniqueId">{{ userQuota }} ({{ usedSpace }})</span>
<NcProgressBar
:aria-labelledby="'quota-progress' + uniqueId"
@ -183,28 +75,7 @@
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')"
:model-value="userLanguage"
label="name"
@input="setUserLanguage" />
</template>
<span v-else-if="!isObfuscated">
<span v-if="!isObfuscated">
{{ userLanguage.name }}
</span>
</td>
@ -240,31 +111,7 @@
</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">
<span v-if="!isObfuscated">
{{ user.manager }}
</span>
</td>
@ -274,7 +121,6 @@
v-if="visible && !isObfuscated && canEdit && !loading.all"
:actions="userActions"
:disabled="isLoadingField"
:edit="editing"
:user="user"
@update:edit="toggleEdit" />
</td>
@ -283,19 +129,15 @@
<script>
import { getCurrentUser } from '@nextcloud/auth'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { showSuccess } from '@nextcloud/dialogs'
import { formatFileSize, parseFileSize } from '@nextcloud/files'
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 logger from '../../logger.ts'
import UserRowMixin from '../../mixins/UserRowMixin.js'
import { loadUserGroups, loadUserSubAdminGroups, searchGroups } from '../../service/groups.ts'
import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts'
import { isObfuscated } from '../../utils/userUtils.ts'
const productName = window.OC.theme.productName
@ -306,8 +148,6 @@ export default {
NcAvatar,
NcLoadingIcon,
NcProgressBar,
NcSelect,
NcTextField,
UserRowActions,
},
@ -350,45 +190,27 @@ export default {
type: Array,
default: () => [],
},
/** Callback from UserList to open the edit dialog */
onEditUser: {
type: Function,
default: null,
},
},
data() {
return {
selectedQuota: false,
rand: Math.random().toString(36).substring(2),
loadingPossibleManagers: false,
possibleManagers: [],
currentManager: '',
editing: false,
loading: {
all: false,
displayName: 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,
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)
},
@ -409,34 +231,22 @@ 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) => {
// 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
const allGroups = this.$store.getters.getGroups
return this.user.groups
.map((id) => {
const group = allGroups.find((g) => g.id === id)
return group?.name ?? 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
const allGroups = this.$store.getters.getGroups
return (this.user.subadmin ?? [])
.map((id) => {
const group = allGroups.find((g) => g.id === id)
return group?.name ?? id
})
.join(', ')
},
@ -458,12 +268,10 @@ export default {
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) {
@ -499,37 +307,15 @@ export default {
}
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: {
toggleEdit() {
if (this.onEditUser) {
this.onEditUser(this.user)
}
},
async wipeUserDevices() {
const userid = this.user.id
await confirmPassword()
@ -562,113 +348,6 @@ export default {
)
},
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)
}
} 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)
}
} 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()
@ -711,242 +390,6 @@ export default {
})
},
/**
* 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 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 })
} 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 })
} catch (error) {
logger.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.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,
})
} catch (error) {
logger.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,
})
} catch (error) {
logger.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) {
logger.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) {
logger.error(error)
}
return lang
},
/**
* Dispatch new welcome mail request
*/
sendWelcomeMail() {
this.loading.all = true
this.$store.dispatch('sendWelcomeMail', this.user.id)
@ -955,21 +398,6 @@ export default {
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>
@ -987,11 +415,6 @@ export default {
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 {
@ -999,12 +422,6 @@ export default {
&__cell {
border-bottom: 1px solid var(--color-border);
:deep {
.v-select.select {
min-width: var(--cell-min-width);
}
}
}
&__progress {

View file

@ -9,12 +9,12 @@
:disabled="disabled"
:inline="1">
<NcActionButton
:data-cy-user-list-action-toggle-edit="`${edit}`"
data-cy-user-list-action-edit
:disabled="disabled"
@click="toggleEdit">
{{ edit ? t('settings', 'Done') : t('settings', 'Edit') }}
@click="$emit('update:edit', true)">
{{ t('settings', 'Edit') }}
<template #icon>
<NcIconSvgWrapper :key="editSvg" :svg="editSvg" aria-hidden="true" />
<NcIconSvgWrapper :svg="SvgPencil" aria-hidden="true" />
</template>
</NcActionButton>
<NcActionButton
@ -36,7 +36,6 @@
<script lang="ts">
import type { PropType } from 'vue'
import SvgCheck from '@mdi/svg/svg/check.svg?raw'
import SvgPencil from '@mdi/svg/svg/pencil-outline.svg?raw'
import isSvg from 'is-svg'
import { defineComponent } from 'vue'
@ -59,50 +58,27 @@ export default defineComponent({
},
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
},
setup() {
return { SvgPencil }
},
/**
* Enabled user row actions
*/
computed: {
enabledActions(): UserAction[] {
return this.actions.filter((action) => typeof action.enabled === 'function' ? action.enabled(this.user) : true)
},
@ -110,13 +86,6 @@ export default defineComponent({
methods: {
isSvg,
/**
* Toggle edit mode by emitting the update event
*/
toggleEdit() {
this.$emit('update:edit', !this.edit)
},
},
})
</script>

View file

@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { formatFileSize } from '@nextcloud/files'
import { useFormatDateTime } from '@nextcloud/vue'
export default {
@ -16,10 +15,6 @@ export default {
type: Object,
default: () => ({}),
},
quotaOptions: {
type: Array,
default: () => [],
},
languages: {
type: Array,
required: true,
@ -42,47 +37,18 @@ export default {
}
},
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
/* QUOTA MANAGEMENT */
usedSpace() {
const quotaUsed = this.user.quota.used > 0 ? this.user.quota.used : 0
return t('settings', '{size} used', { size: formatFileSize(quotaUsed, true) })
},
usedQuota() {
let quota = this.user.quota.quota
if (quota > 0) {
quota = Math.min(100, Math.round(this.user.quota.used / quota * 100))
} else {
const usedInGB = this.user.quota.used / (10 * Math.pow(2, 30))
// asymptotic curve approaching 50% at 10GB to visualize used stace with infinite quota
// asymptotic curve approaching 50% at 10GB to visualize used space with infinite quota
quota = 95 * (1 - (1 / (usedInGB + 1)))
}
return isNaN(quota) ? 0 : quota
},
// Mapping saved values to objects
userQuota() {
if (this.user.quota.quota >= 0) {
// if value is valid, let's map the quotaOptions or return custom quota
const humanQuota = formatFileSize(this.user.quota.quota)
const userQuota = this.quotaOptions.find((quota) => quota.id === humanQuota)
return userQuota || { id: humanQuota, label: humanQuota }
} else if (this.user.quota.quota === 'default') {
// default quota is replaced by the proper value on load
return this.quotaOptions[0]
}
return this.quotaOptions[1] // unlimited
},
/* PASSWORD POLICY? */
minPasswordLength() {
return this.$store.getters.getPasswordPolicyMinLength
},
/* LANGUAGE */
userLanguage() {
const availableLanguages = this.languages[0].languages.concat(this.languages[1].languages)
@ -121,19 +87,5 @@ export default {
}
return t('settings', 'Never')
},
userGroups() {
const allGroups = this.$store.getters.getGroups
return this.user.groups
.map((id) => allGroups.find((g) => g.id === id))
.filter((group) => group !== undefined)
},
userSubAdminGroups() {
const allGroups = this.$store.getters.getGroups
return this.user.subadmin
.map((id) => allGroups.find((g) => g.id === id))
.filter((group) => group !== undefined)
},
},
}