mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
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:
parent
f96730587f
commit
5eb8ef6f18
11 changed files with 913 additions and 1042 deletions
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
278
apps/settings/src/components/Users/EditUserDialog.vue
Normal file
278
apps/settings/src/components/Users/EditUserDialog.vue
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
214
apps/settings/src/components/Users/UserFormFields.vue
Normal file
214
apps/settings/src/components/Users/UserFormFields.vue
Normal 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>
|
||||
129
apps/settings/src/components/Users/UserFormGroups.vue
Normal file
129
apps/settings/src/components/Users/UserFormGroups.vue
Normal 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>
|
||||
65
apps/settings/src/components/Users/UserFormLanguage.vue
Normal file
65
apps/settings/src/components/Users/UserFormLanguage.vue
Normal 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>
|
||||
71
apps/settings/src/components/Users/UserFormManager.vue
Normal file
71
apps/settings/src/components/Users/UserFormManager.vue
Normal 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>
|
||||
53
apps/settings/src/components/Users/UserFormQuota.vue
Normal file
53
apps/settings/src/components/Users/UserFormQuota.vue
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue