fix(settings): auth guards, NcSelectUsers migration, form cleanup

Mirror editUser permission checks in editUserMultiField. Swap NcSelect
:user-select for NcSelectUsers. Extract helpers into userFormUtils.ts.
Simplify UserList form init, drop unused Vue import. Update E2E tests.
-e
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
This commit is contained in:
Peter Ringelmann 2026-04-02 15:45:14 +02:00
parent 48f4b0b956
commit 2bfdd86561
17 changed files with 960 additions and 446 deletions

View file

@ -997,14 +997,21 @@ class UsersController extends AUserDataOCSController
}
if ($language !== null) {
$availableLanguages = $this->l10nFactory->findAvailableLanguages();
if (!in_array($language, $availableLanguages, true) && $language !== 'en') {
$errors['language'] = $this->l10n->t('Invalid language');
$forceLanguage = $this->config->getSystemValue('force_language', false);
if ($forceLanguage !== false && !$isAdmin && !$isDelegatedAdmin) {
$errors['language'] = $this->l10n->t('Language change is not allowed on this instance');
} else {
$availableLanguages = $this->l10nFactory->findAvailableLanguages();
if (!in_array($language, $availableLanguages, true) && $language !== 'en') {
$errors['language'] = $this->l10n->t('Invalid language');
}
}
}
if ($quota !== null) {
if ($quota !== 'none' && $quota !== 'default') {
if (!$canEditOther) {
$errors['quota'] = $this->l10n->t('Insufficient permissions to change quota');
} elseif ($quota !== 'none' && $quota !== 'default') {
if (is_numeric($quota)) {
$quota = (float) $quota;
} else {
@ -1050,6 +1057,10 @@ class UsersController extends AUserDataOCSController
}
}
if ($manager !== null && !$canEditOther) {
$errors['manager'] = $this->l10n->t('Insufficient permissions to change manager');
}
if (!empty($errors)) {
return new DataResponse(['errors' => $errors], Http::STATUS_UNPROCESSABLE_ENTITY);
}
@ -1074,7 +1085,9 @@ class UsersController extends AUserDataOCSController
$targetUser->setSystemEMailAddress(mb_strtolower(trim($email)));
}
if ($quota !== null) {
// Quota and manager can only be set by admins, delegated admins,
// or subadmins with access — never by regular users for themselves.
if ($quota !== null && $canEditOther) {
$targetUser->setQuota($quota);
}
@ -1085,7 +1098,7 @@ class UsersController extends AUserDataOCSController
}
}
if ($manager !== null) {
if ($manager !== null && $canEditOther) {
$targetUser->setManagerUids(array_filter([$manager]));
}
@ -1096,6 +1109,10 @@ class UsersController extends AUserDataOCSController
$this->groupManager->get($gid)?->removeUser($targetUser);
}
foreach (array_diff($groups, $currentGroupIds) as $gid) {
// Only full admins can add users to the admin group
if (!$isAdmin && $gid === 'admin') {
continue;
}
$group = $this->groupManager->get($gid);
if ($group === null) {
continue;
@ -1114,6 +1131,10 @@ class UsersController extends AUserDataOCSController
}
}
foreach (array_diff($subadminGroups, $currentSubAdminGroupIds) as $gid) {
// Cannot create sub-admins for the admin group
if ($gid === 'admin') {
continue;
}
$group = $this->groupManager->get($gid);
if ($group !== null && !$subAdminManager->isSubAdminOfGroup($targetUser, $group)) {
$subAdminManager->createSubAdmin($targetUser, $group);

View file

@ -2713,6 +2713,145 @@ class UsersControllerTest extends TestCase {
$this->assertSame(Http::STATUS_OK, $result->getStatus());
}
public function testUpdateUserSelfEditCannotChangeQuota(): void {
$currentUser = $this->createMock(IUser::class);
$currentUser->method('getUID')->willReturn('regularuser');
$this->userSession->method('getUser')->willReturn($currentUser);
$targetUser = $this->createMock(IUser::class);
$targetUser->method('getUID')->willReturn('regularuser');
$this->userManager->method('get')->with('regularuser')->willReturn($targetUser);
$this->groupManager->method('isAdmin')->willReturn(false);
$this->groupManager->method('isDelegatedAdmin')->willReturn(false);
$subAdmin = $this->createMock(ISubAdmin::class);
$subAdmin->method('isUserAccessible')->willReturn(false);
$this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
$targetUser->expects($this->never())->method('setQuota');
$result = $this->api->editUserMultiField('regularuser', quota: 'none');
$this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus());
$this->assertArrayHasKey('quota', $result->getData()['errors']);
}
public function testUpdateUserSelfEditCannotChangeManager(): void {
$currentUser = $this->createMock(IUser::class);
$currentUser->method('getUID')->willReturn('regularuser');
$this->userSession->method('getUser')->willReturn($currentUser);
$targetUser = $this->createMock(IUser::class);
$targetUser->method('getUID')->willReturn('regularuser');
$this->userManager->method('get')->with('regularuser')->willReturn($targetUser);
$this->groupManager->method('isAdmin')->willReturn(false);
$this->groupManager->method('isDelegatedAdmin')->willReturn(false);
$subAdmin = $this->createMock(ISubAdmin::class);
$subAdmin->method('isUserAccessible')->willReturn(false);
$this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
$targetUser->expects($this->never())->method('setManagerUids');
$result = $this->api->editUserMultiField('regularuser', manager: 'boss');
$this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus());
$this->assertArrayHasKey('manager', $result->getData()['errors']);
}
public function testUpdateUserDelegatedAdminCannotAddToAdminGroup(): void {
$currentUser = $this->createMock(IUser::class);
$currentUser->method('getUID')->willReturn('delegatedadmin');
$this->userSession->method('getUser')->willReturn($currentUser);
$targetUser = $this->createMock(IUser::class);
$targetUser->method('getUID')->willReturn('targetuser');
$targetUser->method('getBackend')->willReturn($this->createMock(UserInterface::class));
$this->userManager->method('get')->with('targetuser')->willReturn($targetUser);
$this->groupManager->method('isAdmin')->willReturn(false);
$this->groupManager->method('isDelegatedAdmin')->with('delegatedadmin')->willReturn(true);
$this->groupManager->method('isInGroup')->with('targetuser', 'admin')->willReturn(false);
$subAdmin = $this->createMock(ISubAdmin::class);
$this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
$this->groupManager->method('groupExists')->willReturn(true);
$this->groupManager->method('getUserGroups')->willReturn([]);
$adminGroup = $this->createMock(IGroup::class);
$adminGroup->method('getGID')->willReturn('admin');
// The admin group's addUser must never be called
$adminGroup->expects($this->never())->method('addUser');
$normalGroup = $this->createMock(IGroup::class);
$normalGroup->method('getGID')->willReturn('staff');
$normalGroup->expects($this->once())->method('addUser')->with($targetUser);
$this->groupManager->method('get')->willReturnMap([
['admin', $adminGroup],
['staff', $normalGroup],
]);
$result = $this->api->editUserMultiField('targetuser', groups: ['admin', 'staff']);
$this->assertSame(Http::STATUS_OK, $result->getStatus());
}
public function testUpdateUserCannotCreateSubAdminOfAdminGroup(): void {
$currentUser = $this->createMock(IUser::class);
$currentUser->method('getUID')->willReturn('admin');
$this->userSession->method('getUser')->willReturn($currentUser);
$targetUser = $this->createMock(IUser::class);
$targetUser->method('getUID')->willReturn('targetuser');
$targetUser->method('getBackend')->willReturn($this->createMock(UserInterface::class));
$this->userManager->method('get')->with('targetuser')->willReturn($targetUser);
$this->groupManager->method('isAdmin')->with('admin')->willReturn(true);
$this->groupManager->method('isDelegatedAdmin')->willReturn(false);
$subAdmin = $this->createMock(ISubAdmin::class);
$subAdmin->method('getSubAdminsGroups')->willReturn([]);
$subAdmin->method('isSubAdminOfGroup')->willReturn(false);
// createSubAdmin must never be called for the admin group
$subAdmin->expects($this->never())->method('createSubAdmin');
$this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
$this->groupManager->method('groupExists')->willReturn(true);
$this->groupManager->method('getUserGroups')->willReturn([]);
$adminGroup = $this->createMock(IGroup::class);
$adminGroup->method('getGID')->willReturn('admin');
$this->groupManager->method('get')->willReturnMap([
['admin', $adminGroup],
]);
$result = $this->api->editUserMultiField('targetuser', subadminGroups: ['admin']);
$this->assertSame(Http::STATUS_OK, $result->getStatus());
}
public function testUpdateUserForceLanguageBlocksNonAdmin(): void {
$currentUser = $this->createMock(IUser::class);
$currentUser->method('getUID')->willReturn('regularuser');
$this->userSession->method('getUser')->willReturn($currentUser);
$targetUser = $this->createMock(IUser::class);
$targetUser->method('getUID')->willReturn('regularuser');
$this->userManager->method('get')->with('regularuser')->willReturn($targetUser);
$this->groupManager->method('isAdmin')->willReturn(false);
$this->groupManager->method('isDelegatedAdmin')->willReturn(false);
$subAdmin = $this->createMock(ISubAdmin::class);
$subAdmin->method('isUserAccessible')->willReturn(false);
$this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
// force_language is set — regular users cannot change language
$this->config->method('getSystemValue')
->with('force_language', false)
->willReturn('en');
$result = $this->api->editUserMultiField('regularuser', language: 'de');
$this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus());
$this->assertArrayHasKey('language', $result->getData()['errors']);
}
public function testDeleteUserNotExistingUser(): void {
$this->expectException(OCSException::class);

View file

@ -10,7 +10,6 @@
:loading="loading"
:new-user="newUser"
:quota-options="quotaOptions"
@reset="resetForm"
@closing="closeDialog" />
<EditUserDialog
@ -71,7 +70,6 @@
<script>
import { mdiAccountGroupOutline } from '@mdi/js'
import { showError } from '@nextcloud/dialogs'
import Vue from 'vue'
import { Fragment } from 'vue-frag'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
@ -268,7 +266,7 @@ export default {
/**
* Reset and init new user form
*/
this.resetForm()
this.initForm()
/**
* If disabled group but empty, redirect
@ -324,25 +322,11 @@ export default {
})
},
resetForm() {
// revert form to original state
this.newUser = { ...newUser }
/**
* Init default language from server data. The use of this.settings
* requires a computed variable, which break the v-model binding of the form,
* this is a much easier solution than getter and setter on a computed var
*/
initForm() {
if (this.settings.defaultLanguage) {
Vue.set(this.newUser.language, 'code', this.settings.defaultLanguage)
this.newUser.language.code = this.settings.defaultLanguage
}
/**
* In case the user directly loaded the user list within a group
* the watch won't be triggered. We need to initialize it.
*/
this.setNewUserDefaultGroup(this.selectedGroup)
this.loading.all = false
},

View file

@ -7,8 +7,8 @@
<NcDialog
class="edit-dialog"
size="small"
:name="t('settings', 'Edit account') + ' - ' + user.id"
out-transition
:name="t('settings', 'Edit account')"
outTransition
@closing="$emit('closing')">
<form
id="edit-user-form"
@ -17,7 +17,6 @@
:disabled="saving"
@submit.prevent="save">
<UserFormFields
:formData="editedUser"
:fieldConfig="fieldConfig"
:errors="fieldErrors"
:quotaOptions="quotaOptions" />
@ -39,126 +38,12 @@
<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
}
import { diffPayload, userToFormData } from './userFormUtils.ts'
export default {
name: 'EditUserDialog',
@ -169,6 +54,14 @@ export default {
UserFormFields,
},
// Children inject this reactive object and mutate its properties via v-model.
// Do not reassign editedUser entirely, the injected reference would go stale.
provide() {
return {
formData: this.editedUser,
}
},
props: {
user: {
type: Object,
@ -204,6 +97,12 @@ export default {
fieldConfig() {
return {
username: {
show: true,
disabled: true,
label: t('settings', 'Account name'),
},
password: {
show: this.settings.canChangePassword && this.user.backendCapabilities.setPassword,
label: t('settings', 'New password'),

View file

@ -18,9 +18,8 @@
@submit.prevent="createUser">
<UserFormFields
ref="fields"
:formData="newUser"
:fieldConfig="fieldConfig"
:quotaOptions="quotaOptions" />
:field-config="fieldConfig"
:quota-options="quotaOptions" />
</form>
<template #actions>
@ -50,6 +49,14 @@ export default {
UserFormFields,
},
// Children inject this reactive object and mutate its properties via v-model.
// Do not reassign newUser entirely, the injected reference would go stale.
provide() {
return {
formData: this.newUser,
}
},
props: {
loading: {
type: Object,
@ -67,7 +74,7 @@ export default {
},
},
emits: ['closing', 'reset'],
emits: ['closing'],
computed: {
settings() {
@ -99,6 +106,7 @@ export default {
label: this.newUser.email === ''
? t('settings', 'Password (required)')
: t('settings', 'Password'),
required: this.newUser.email === '',
},
@ -106,6 +114,7 @@ export default {
label: this.newUser.password === '' || this.settings.newUserRequireEmail
? t('settings', 'Email (required)')
: t('settings', 'Email'),
required: this.newUser.password === '' || this.settings.newUserRequireEmail,
},
@ -134,8 +143,6 @@ export default {
manager: this.newUser.manager.id,
})
this.$emit('reset')
this.$refs.fields?.focusField('username')
this.$emit('closing')
} catch (error) {
this.loading.all = false

View file

@ -45,7 +45,7 @@
data-test="password"
:minlength="minPasswordLength"
:maxlength="469"
aria-describedby="password-email-hint"
:aria-describedby="fieldConfig.showPasswordEmailHint ? 'password-email-hint' : undefined"
:label="fieldConfig.password?.label"
:error="!!errors.password"
:helper-text="errors.password"
@ -59,7 +59,7 @@
class="user-form-fields__item"
data-test="email"
type="email"
aria-describedby="password-email-hint"
:aria-describedby="fieldConfig.showPasswordEmailHint ? 'password-email-hint' : undefined"
:label="fieldConfig.email?.label || t('settings', 'Email')"
:error="!!errors.email"
:helper-text="errors.email"
@ -68,10 +68,21 @@
spellcheck="false"
:required="fieldConfig.email?.required" />
<UserFormGroups :formData="formData" />
<UserFormQuota :formData="formData" :quotaOptions="quotaOptions" />
<UserFormLanguage :formData="formData" />
<UserFormManager :formData="formData" />
<UserFormGroups />
<UserFormQuota :quota-options="quotaOptions" />
<UserFormLanguage />
<UserFormManager />
<!-- Catch-all for validation errors on NcSelect-based fields (groups, quota, etc.) -->
<div
v-if="Object.keys(unhandledErrors).length > 0"
class="user-form-fields__error-summary"
aria-live="polite"
role="status">
<p v-for="(message, field) in unhandledErrors" :key="field">
{{ field }}: {{ message }}
</p>
</div>
</div>
</template>
@ -86,10 +97,10 @@ 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)
* Injects a reactive `formData` object (provided 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.
* that also inject the same formData.
*
* Expected formData shape:
* { username, displayName, password, email, groups, subadminGroups, quota, language, manager }
@ -106,13 +117,9 @@ export default {
UserFormQuota,
},
props: {
/** The mutable form data object owned by the parent dialog */
formData: {
type: Object,
required: true,
},
inject: ['formData'],
props: {
/** Quota preset options for the quota select */
quotaOptions: {
type: Array,
@ -144,6 +151,11 @@ export default {
minPasswordLength() {
return this.$store.getters.getPasswordPolicyMinLength
},
unhandledErrors() {
const handled = new Set(['displayName', 'password', 'email'])
return Object.fromEntries(Object.entries(this.errors).filter(([key]) => !handled.has(key)))
},
},
methods: {
@ -187,5 +199,16 @@ export default {
:deep(.user-form__managers) {
margin-bottom: 12px;
}
&__error-summary {
width: 100%;
margin-top: 8px;
color: var(--color-error);
font-size: var(--default-font-size, 0.875rem);
p {
margin: 2px 0;
}
}
}
</style>

View file

@ -31,7 +31,7 @@
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 …')"
:placeholder="t('settings', 'Set account as admin for …')"
:disabled="creatingGroup"
:options="availableSubAdminGroups"
keep-open
@ -54,12 +54,7 @@ export default {
NcSelect,
},
props: {
formData: {
type: Object,
required: true,
},
},
inject: ['formData'],
data() {
return {

View file

@ -22,6 +22,7 @@
<script>
import NcSelect from '@nextcloud/vue/components/NcSelect'
import { languageFilterBy } from './userFormUtils.ts'
export default {
name: 'UserFormLanguage',
@ -30,12 +31,7 @@ export default {
NcSelect,
},
props: {
formData: {
type: Object,
required: true,
},
},
inject: ['formData'],
computed: {
showConfig() {
@ -54,12 +50,7 @@ export default {
},
methods: {
languageFilterBy(option, label, search) {
if (option.languages) {
return option.languages.some(({ name }) => name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
}
return (label || '').toLocaleLowerCase().includes(search.toLocaleLowerCase())
},
languageFilterBy,
},
}
</script>

View file

@ -5,65 +5,87 @@
<template>
<div class="user-form__item user-form__managers">
<NcSelect
v-model="formData.manager"
<NcSelectUsers
:modelValue="managerModel"
class="user-form__select"
:input-label="t('settings', 'Manager')"
:placeholder="t('settings', 'Set line manager')"
:options="possibleManagers"
:user-select="true"
label="displayname"
:clearable="true"
:placeholder="t('settings', 'Search for a manager…')"
:options="managerOptions"
:loading="loading"
@open="onOpen"
@update:modelValue="onManagerChange"
@search="searchUserManager" />
</div>
</template>
<script>
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcSelectUsers from '@nextcloud/vue/components/NcSelectUsers'
import logger from '../../logger.ts'
export default {
name: 'UserFormManager',
components: {
NcSelect,
NcSelectUsers,
},
props: {
formData: {
type: Object,
required: true,
},
},
inject: ['formData'],
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
computed: {
/** Map internal formData.manager to NcSelectUsersModel shape */
managerModel() {
const m = this.formData.manager
if (!m) {
return null
}
return {
id: typeof m === 'object' ? m.id : m,
displayName: typeof m === 'object' ? (m.displayname ?? m.id) : m,
}
},
/** Map API users to NcSelectUsersModel shape */
managerOptions() {
return this.possibleManagers.map((u) => ({
id: u.id,
displayName: u.displayname ?? u.id,
subname: u.email ?? '',
}))
},
},
mounted() {
this.searchUserManager('')
},
methods: {
/** Map NcSelectUsersModel back to internal formData shape */
onManagerChange(value) {
this.formData.manager = value
? { id: value.id, displayname: value.displayName }
: ''
},
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.loading = true
try {
const response = await this.$store.dispatch('searchUsers', {
offset: 0,
limit: 10,
search: query,
})
const users = response?.data ? Object.values(response.data.ocs.data.users) : []
this.possibleManagers = users
} catch (error) {
logger.error('Failed to search user managers', { error })
} finally {
this.loading = false
}
},
},

View file

@ -18,8 +18,8 @@
</template>
<script>
import { formatFileSize, parseFileSize } from '@nextcloud/files'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import { validateQuota } from './userFormUtils.ts'
export default {
name: 'UserFormQuota',
@ -28,11 +28,9 @@ export default {
NcSelect,
},
inject: ['formData'],
props: {
formData: {
type: Object,
required: true,
},
quotaOptions: {
type: Array,
required: true,
@ -41,12 +39,7 @@ export default {
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]
return validateQuota(quota, this.quotaOptions[0])
},
},
}

View file

@ -0,0 +1,331 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { describe, expect, it } from 'vitest'
import { diffPayload, languageFilterBy, resolveLanguage, userToFormData, validateQuota } from './userFormUtils.ts'
describe('resolveLanguage', () => {
const serverLanguages = {
commonLanguages: [
{ code: 'en', name: 'English' },
{ code: 'de', name: 'Deutsch' },
],
otherLanguages: [
{ code: 'ja', name: '日本語' },
],
}
it('returns empty language when user has no language set', () => {
expect(resolveLanguage({ language: '' }, serverLanguages)).toEqual({ code: '', name: '' })
})
it('returns empty language when user language is undefined', () => {
expect(resolveLanguage({}, serverLanguages)).toEqual({ code: '', name: '' })
})
it('resolves a common language', () => {
expect(resolveLanguage({ language: 'de' }, serverLanguages)).toEqual({ code: 'de', name: 'Deutsch' })
})
it('resolves an other language', () => {
expect(resolveLanguage({ language: 'ja' }, serverLanguages)).toEqual({ code: 'ja', name: '日本語' })
})
it('falls back to code as name for unknown languages', () => {
expect(resolveLanguage({ language: 'xx' }, serverLanguages)).toEqual({ code: 'xx', name: 'xx' })
})
it('handles missing serverLanguages gracefully', () => {
expect(resolveLanguage({ language: 'en' }, null)).toEqual({ code: 'en', name: 'en' })
expect(resolveLanguage({ language: 'en' }, {})).toEqual({ code: 'en', name: 'en' })
})
})
describe('userToFormData', () => {
const allGroups = [
{ id: 'admin', name: 'Admin' },
{ id: 'devs', name: 'Developers' },
{ id: 'design', name: 'Design' },
]
const quotaOptions = [
{ id: 'default', label: 'Default quota' },
{ id: 'none', label: 'Unlimited' },
{ id: '1 GB', label: '1 GB' },
]
const serverLanguages = {
commonLanguages: [{ code: 'en', name: 'English' }],
otherLanguages: [],
}
it('maps a full user object to form data', () => {
const user = {
id: 'bob',
displayname: 'Bob Smith',
email: 'bob@example.com',
groups: ['admin', 'devs'],
subadmin: ['devs'],
quota: { quota: 1073741824 }, // 1 GB
language: 'en',
manager: 'alice',
}
const result = userToFormData(user, allGroups, quotaOptions, serverLanguages)
expect(result.username).toBe('bob')
expect(result.displayName).toBe('Bob Smith')
expect(result.password).toBe('')
expect(result.email).toBe('bob@example.com')
expect(result.groups).toEqual([
{ id: 'admin', name: 'Admin' },
{ id: 'devs', name: 'Developers' },
])
expect(result.subadminGroups).toEqual([
{ id: 'devs', name: 'Developers' },
])
expect(result.quota).toEqual({ id: '1 GB', label: '1 GB' })
expect(result.language).toEqual({ code: 'en', name: 'English' })
expect(result.manager).toBe('alice')
})
it('defaults missing fields gracefully', () => {
const user = {
id: 'minimal',
groups: [],
quota: {},
}
const result = userToFormData(user, allGroups, quotaOptions, serverLanguages)
expect(result.displayName).toBe('')
expect(result.email).toBe('')
expect(result.manager).toBe('')
expect(result.groups).toEqual([])
expect(result.subadminGroups).toEqual([])
})
it('uses default quota when quota is "default"', () => {
const user = { id: 'u1', groups: [], quota: { quota: 'default' } }
const result = userToFormData(user, allGroups, quotaOptions, serverLanguages)
expect(result.quota).toEqual({ id: 'default', label: 'Default quota' })
})
it('uses unlimited quota when quota is unset', () => {
const user = { id: 'u1', groups: [], quota: { quota: 'none' } }
const result = userToFormData(user, allGroups, quotaOptions, serverLanguages)
expect(result.quota.id).toBe('none')
})
it('filters out groups that do not exist in allGroups', () => {
const user = { id: 'u1', groups: ['admin', 'nonexistent'], quota: {} }
const result = userToFormData(user, allGroups, quotaOptions, serverLanguages)
expect(result.groups).toEqual([{ id: 'admin', name: 'Admin' }])
})
})
describe('diffPayload', () => {
function makeFormData(overrides = {}) {
return {
username: 'bob',
displayName: 'Bob',
password: '',
email: 'bob@example.com',
groups: [{ id: 'devs', name: 'Developers' }],
subadminGroups: [],
quota: { id: '1 GB', label: '1 GB' },
language: { code: 'en', name: 'English' },
manager: 'alice',
...overrides,
}
}
it('returns empty object when nothing changed', () => {
const data = makeFormData()
expect(diffPayload(data, { ...data })).toEqual({})
})
it('detects displayName change', () => {
const initial = makeFormData()
const current = makeFormData({ displayName: 'Robert' })
expect(diffPayload(initial, current)).toEqual({ displayName: 'Robert' })
})
it('always includes password when non-empty', () => {
const initial = makeFormData()
const current = makeFormData({ password: 'secret123' })
expect(diffPayload(initial, current)).toEqual({ password: 'secret123' })
})
it('does not include password when empty', () => {
const initial = makeFormData()
const current = makeFormData({ password: '' })
expect(diffPayload(initial, current)).toEqual({})
})
it('detects email change', () => {
const initial = makeFormData()
const current = makeFormData({ email: 'new@example.com' })
expect(diffPayload(initial, current)).toEqual({ email: 'new@example.com' })
})
it('detects quota change', () => {
const initial = makeFormData()
const current = makeFormData({ quota: { id: '5 GB', label: '5 GB' } })
expect(diffPayload(initial, current)).toEqual({ quota: '5 GB' })
})
it('detects language change', () => {
const initial = makeFormData()
const current = makeFormData({ language: { code: 'de', name: 'Deutsch' } })
expect(diffPayload(initial, current)).toEqual({ language: 'de' })
})
it('detects manager change from string to object', () => {
const initial = makeFormData({ manager: 'alice' })
const current = makeFormData({ manager: { id: 'charlie', displayname: 'Charlie' } })
expect(diffPayload(initial, current)).toEqual({ manager: 'charlie' })
})
it('detects manager change from object to string', () => {
const initial = makeFormData({ manager: { id: 'alice', displayname: 'Alice' } })
const current = makeFormData({ manager: 'bob' })
expect(diffPayload(initial, current)).toEqual({ manager: 'bob' })
})
it('no diff when manager string matches object id', () => {
const initial = makeFormData({ manager: 'alice' })
const current = makeFormData({ manager: { id: 'alice', displayname: 'Alice' } })
expect(diffPayload(initial, current)).toEqual({})
})
it('detects manager cleared (object to empty string)', () => {
const initial = makeFormData({ manager: { id: 'alice', displayname: 'Alice' } })
const current = makeFormData({ manager: '' })
expect(diffPayload(initial, current)).toEqual({ manager: '' })
})
it('handles manager with null id', () => {
const initial = makeFormData({ manager: '' })
const current = makeFormData({ manager: { id: null } })
expect(diffPayload(initial, current)).toEqual({})
})
it('detects groups added', () => {
const initial = makeFormData({ groups: [{ id: 'devs' }] })
const current = makeFormData({ groups: [{ id: 'devs' }, { id: 'admin' }] })
const result = diffPayload(initial, current)
expect(result.groups).toEqual(['admin', 'devs'])
})
it('detects groups removed', () => {
const initial = makeFormData({ groups: [{ id: 'devs' }, { id: 'admin' }] })
const current = makeFormData({ groups: [{ id: 'devs' }] })
expect(diffPayload(initial, current)).toEqual({ groups: ['devs'] })
})
it('ignores group reordering', () => {
const initial = makeFormData({ groups: [{ id: 'admin' }, { id: 'devs' }] })
const current = makeFormData({ groups: [{ id: 'devs' }, { id: 'admin' }] })
expect(diffPayload(initial, current)).toEqual({})
})
it('detects subadmin groups change', () => {
const initial = makeFormData({ subadminGroups: [] })
const current = makeFormData({ subadminGroups: [{ id: 'devs' }] })
expect(diffPayload(initial, current)).toEqual({ subadminGroups: ['devs'] })
})
it('detects multiple changes at once', () => {
const initial = makeFormData()
const current = makeFormData({
displayName: 'Robert',
password: 'newpass',
email: 'new@example.com',
})
const result = diffPayload(initial, current)
expect(result).toEqual({
displayName: 'Robert',
password: 'newpass',
email: 'new@example.com',
})
})
})
describe('validateQuota', () => {
const fallback = { id: 'default', label: 'Default quota' }
it('parses a valid quota string', () => {
expect(validateQuota('1 GB', fallback)).toEqual({ id: '1 GB', label: '1 GB' })
})
it('normalizes quota formatting', () => {
const result = validateQuota('1073741824', fallback)
expect(result).toEqual({ id: '1 GB', label: '1 GB' })
})
it('parses small quota values', () => {
const result = validateQuota('4 MB', fallback)
expect(result.id).toBe('4 MB')
})
it('returns fallback for invalid input', () => {
expect(validateQuota('not a size', fallback)).toEqual(fallback)
})
it('returns fallback for empty string', () => {
expect(validateQuota('', fallback)).toEqual(fallback)
})
it('returns fallback for negative values', () => {
expect(validateQuota('-5 GB', fallback)).toEqual(fallback)
})
it('accepts zero as a valid quota', () => {
const result = validateQuota('0', fallback)
expect(result).toEqual({ id: '0 B', label: '0 B' })
})
})
describe('languageFilterBy', () => {
it('matches a plain language option by label', () => {
expect(languageFilterBy({}, 'English', 'eng')).toBe(true)
})
it('rejects a non-matching plain option', () => {
expect(languageFilterBy({}, 'English', 'deu')).toBe(false)
})
it('is case-insensitive', () => {
expect(languageFilterBy({}, 'Deutsch', 'DEUT')).toBe(true)
})
it('matches a group header if any nested language matches', () => {
const group = {
languages: [
{ name: 'English' },
{ name: 'Deutsch' },
],
}
expect(languageFilterBy(group, 'Common languages', 'deut')).toBe(true)
})
it('rejects a group header if no nested language matches', () => {
const group = {
languages: [
{ name: 'English' },
],
}
expect(languageFilterBy(group, 'Common languages', 'fran')).toBe(false)
})
it('handles empty label gracefully', () => {
expect(languageFilterBy({}, '', 'test')).toBe(false)
})
it('handles null label gracefully', () => {
expect(languageFilterBy({}, null as unknown as string, 'test')).toBe(false)
})
})

View file

@ -0,0 +1,179 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IGroup } from '../../views/user-types.d.ts'
import { formatFileSize, parseFileSize } from '@nextcloud/files'
import { unlimitedQuota } from '../../utils/userUtils.ts'
interface QuotaOption {
id: string
label: string
}
interface LanguageOption {
code: string
name: string
}
interface FormData {
username: string
displayName: string
password: string
email: string
groups: IGroup[]
subadminGroups: IGroup[]
quota: QuotaOption
language: LanguageOption
manager: string | { id: string, displayname?: string }
}
/**
* Resolves the user's language code to a { code, name } object.
*
* @param user The user store object
* @param serverLanguages Server language configuration
* @return Language object with code and name
*/
export function resolveLanguage(user, serverLanguages): LanguageOption {
if (!user.language || user.language === '') {
return { code: '', name: '' }
}
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 }
}
/**
* 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 user The user store object
* @param allGroups All available groups from the store
* @param quotaOptions Quota preset options
* @param serverLanguages Server language configuration
* @return Form-ready data object
*/
export 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 ?? '',
}
}
/**
* Generic shallow diff between initial and current form data.
* Returns only fields that changed, with API-ready values.
*
* @param initial Snapshot of form data at mount time
* @param current Current form data state
* @return Changed fields with API-ready values
*/
export function diffPayload(initial: FormData, current: FormData) {
const payload: Record<string, string | string[]> = {}
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
}
/**
* Parses and normalizes a user-entered quota string into a quota option.
* Returns the fallback option if the input is invalid.
*
* @param quota Raw quota string entered by the user (e.g. "4 MB")
* @param fallback Fallback option when input is invalid
* @param fallback.id Fallback option identifier
* @param fallback.label Fallback option display label
* @return Normalized quota option with id and label
*/
export function validateQuota(quota: string, fallback: { id: string, label: string }) {
const parsed = parseFileSize(quota, true)
if (parsed !== null && parsed >= 0) {
const label = formatFileSize(parsed)
return { id: label, label }
}
return fallback
}
/**
* Filter function for the language NcSelect. Handles grouped options
* (section headers with nested languages) and plain language entries.
*
* @param option The select option being filtered
* @param option.languages Nested languages for group headers
* @param label The option's display label
* @param search The user's search string
* @return Whether the option matches the search
*/
export function languageFilterBy(option: { languages?: Array<{ name: string }> }, label: string, search: string): boolean {
if (option.languages) {
return option.languages.some(({ name }) => name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
}
return (label || '').toLocaleLowerCase().includes(search.toLocaleLowerCase())
}

View file

@ -2273,12 +2273,14 @@
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getUserValue]]></code>
<code><![CDATA[implementsActions]]></code>
<code><![CDATA[implementsActions]]></code>
<code><![CDATA[search]]></code>
<code><![CDATA[search]]></code>
<code><![CDATA[setUserValue]]></code>
<code><![CDATA[setUserValue]]></code>
<code><![CDATA[setUserValue]]></code>
<code><![CDATA[setUserValue]]></code>
<code><![CDATA[setUserValue]]></code>
</DeprecatedMethod>
<TypeDoesNotContainNull>
<code><![CDATA[$groupid === null]]></code>

View file

@ -51,24 +51,26 @@ export function waitLoading(selector: string) {
}
/**
* Toggle the edit button of the user row
* Open the edit dialog for a user by clicking the Edit action on their row
*
* @param user The user row to edit
* @param toEdit True if it should be switch to edit mode, false to switch to read-only
* @param user The user whose edit dialog to open
*/
export function toggleEditButton(user: User, toEdit = true) {
// see that the list of users contains the user
export function openEditDialog(user: User) {
getUserListRow(user.userId).should('exist')
// toggle the edit mode for the user
.find('[data-cy-user-list-cell-actions]')
.find(`[data-cy-user-list-action-toggle-edit="${!toEdit}"]`)
.if()
.find('[data-cy-user-list-action-edit]')
.click({ force: true })
.else()
// otherwise ensure the button is already in edit mode
.then(() => getUserListRow(user.userId)
.find(`[data-cy-user-list-action-toggle-edit="${toEdit}"]`)
.should('exist'))
// Wait for the dialog to appear
cy.get('.edit-dialog [data-test="form"]').should('be.visible')
}
/**
* Save the currently open edit dialog by clicking the Save button
* and wait for the dialog to close
*/
export function saveEditDialog() {
cy.get('[data-test="submit"]').click()
// Wait for dialog to close
cy.get('.edit-dialog').should('not.exist')
}
/**

View file

@ -6,7 +6,7 @@
import { User } from '@nextcloud/e2e-test-server/cypress'
import { clearState } from '../../support/commonUtils.ts'
import { randomString } from '../../support/utils/randomString.ts'
import { assertNotExistOrNotVisible, getUserListRow, handlePasswordConfirmation, toggleEditButton } from './usersUtils.ts'
import { assertNotExistOrNotVisible, getUserListRow, handlePasswordConfirmation, openEditDialog, saveEditDialog } from './usersUtils.ts'
const admin = new User('admin', 'admin')
@ -89,34 +89,27 @@ describe('Settings: Assign user to a group', { testIsolation: false }, () => {
.scrollIntoView()
})
it('switch into user edit mode', () => {
toggleEditButton(testUser)
getUserListRow(testUser.userId)
.find('[data-cy-user-list-input-groups]')
.should('exist')
})
it('assign the group via the edit dialog', () => {
openEditDialog(testUser)
it('assign the group', () => {
// focus inside the input
getUserListRow(testUser.userId)
.find('[data-cy-user-list-input-groups] input')
.click({ force: true })
// enter the group name
getUserListRow(testUser.userId)
.find('[data-cy-user-list-input-groups] input')
.type(`${groupName.slice(0, 5)}`) // only type part as otherwise we would create a new one with the same name
cy.contains('li.vs__dropdown-option', groupName)
.click({ force: true })
// Type part of the group name in the groups NcSelect
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.get('[data-test="groups"] input[type="search"]').click({ force: true })
cy.get('[data-test="groups"] input[type="search"]').type(groupName.slice(0, 5))
})
// Select the group from the floating dropdown
cy.get('.vs__dropdown-menu').should('be.visible')
.contains('li', groupName).click({ force: true })
handlePasswordConfirmation(admin.password)
})
saveEditDialog()
it('leave the user edit mode', () => {
toggleEditButton(testUser, false)
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
})
it('see the group was successfully assigned', () => {
// see a new memeber
// see a new member
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.find('.counter-bubble__counter')
.should('contain', '1')

View file

@ -5,7 +5,7 @@
import { User } from '@nextcloud/e2e-test-server/cypress'
import { clearState } from '../../support/commonUtils.ts'
import { getUserListRow, handlePasswordConfirmation, toggleEditButton, waitLoading } from './usersUtils.ts'
import { handlePasswordConfirmation, openEditDialog, saveEditDialog } from './usersUtils.ts'
const admin = new User('admin', 'admin')
@ -21,101 +21,57 @@ describe('Settings: User Manager Management', function() {
}).then(($user) => {
user = $user
cy.login(admin)
cy.intercept('PUT', `/ocs/v2.php/cloud/users/${user.userId}*`).as('updateUser')
})
})
it('Can assign and remove a manager through the UI', function() {
it('Can assign a manager through the edit dialog', function() {
cy.visit('/settings/users')
toggleEditButton(user, true)
openEditDialog(user)
// Scroll to manager cell and wait for it to be visible
getUserListRow(user.userId)
.find('[data-cy-user-list-cell-manager]')
.scrollIntoView()
.should('be.visible')
// Assign a manager
getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => {
// Verify no manager is set initially
cy.get('.vs__selected').should('not.exist')
// Open the dropdown menu
cy.get('[role="combobox"]').click({ force: true })
// Wait for the dropdown to be visible and initialized
waitLoading('[data-cy-user-list-input-manager]')
// Type the manager's username to search
cy.get('input[type="search"]').type(manager.userId, { force: true })
// Wait for the search results to load
waitLoading('[data-cy-user-list-input-manager]')
// Open the Manager NcSelect and type manager name
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.findByRole('combobox', { name: /Manager/i }).click({ force: true })
cy.findByRole('combobox', { name: /Manager/i }).type(manager.userId)
})
// Now select the manager from the filtered results
// Since the dropdown is floating, we need to search globally
cy.get('.vs__dropdown-menu').find('li').contains('span', manager.userId).should('be.visible').click({ force: true })
// Select the manager from the floating dropdown
cy.get('.vs__dropdown-menu').should('be.visible')
.contains('li', manager.userId).click({ force: true })
// Handle password confirmation if needed
handlePasswordConfirmation(admin.password)
saveEditDialog()
// Verify the manager is selected in the UI
cy.get('.vs__selected').should('exist').and('contain.text', manager.userId)
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify the PUT request was made to set the manager
cy.wait('@updateUser').then((interception) => {
// Verify the request URL and body
expect(interception.request.url).to.match(/\/cloud\/users\/.+/)
expect(interception.request.body).to.deep.equal({
key: 'manager',
value: manager.userId,
})
expect(interception.response?.statusCode).to.equal(200)
})
// Wait for the save to complete
waitLoading('[data-cy-user-list-input-manager]')
// Verify the manager is set in the backend
// Verify backend
cy.getUserData(user).then(($result) => {
expect($result.body).to.contain(`<manager>${manager.userId}</manager>`)
})
})
// Now remove the manager
getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => {
// Clear the manager selection
cy.get('.vs__clear').click({ force: true })
it('Can remove a manager through the edit dialog', function() {
// Set manager via backend first
cy.runOccCommand(`user:setting '${user.userId}' settings manager '${manager.userId}'`)
// Verify the manager is cleared in the UI
cy.get('.vs__selected').should('not.exist')
cy.visit('/settings/users')
// Handle password confirmation if needed
handlePasswordConfirmation(admin.password)
openEditDialog(user)
// Clear the manager selection inside the dialog
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.get('.user-form__managers .vs__clear').click({ force: true })
})
// Verify the PUT request was made to clear the manager
cy.wait('@updateUser').then((interception) => {
// Verify the request URL and body
expect(interception.request.url).to.match(/\/cloud\/users\/.+/)
expect(interception.request.body).to.deep.equal({
key: 'manager',
value: '',
})
expect(interception.response?.statusCode).to.equal(200)
})
handlePasswordConfirmation(admin.password)
saveEditDialog()
// Wait for the save to complete
waitLoading('[data-cy-user-list-input-manager]')
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify the manager is cleared in the backend
// Verify backend
cy.getUserData(user).then(($result) => {
expect($result.body).to.not.contain(`<manager>${manager.userId}</manager>`)
expect($result.body).to.contain('<manager></manager>')
})
// Finish editing the user
toggleEditButton(user, false)
})
})

View file

@ -5,7 +5,7 @@
import { User } from '@nextcloud/e2e-test-server/cypress'
import { clearState } from '../../support/commonUtils.ts'
import { getUserListRow, handlePasswordConfirmation, toggleEditButton, waitLoading } from './usersUtils.ts'
import { handlePasswordConfirmation, openEditDialog, saveEditDialog } from './usersUtils.ts'
const admin = new User('admin', 'admin')
@ -21,95 +21,96 @@ describe('Settings: Change user properties', function() {
})
it('Can change the display name', function() {
// open the User settings as admin
cy.visit('/settings/users')
// toggle edit button into edit mode
toggleEditButton(user, true)
openEditDialog(user)
getUserListRow(user.userId).within(() => {
// set the display name
cy.get('[data-cy-user-list-input-displayname]').should('exist').and('have.value', user.userId)
cy.get('[data-cy-user-list-input-displayname]').clear()
cy.get('[data-cy-user-list-input-displayname]').type('John Doe')
cy.get('[data-cy-user-list-input-displayname]').should('have.value', 'John Doe')
cy.get('[data-cy-user-list-input-displayname] ~ button').click()
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
// see that the display name cell is done loading
waitLoading('[data-cy-user-list-input-displayname]')
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.get('input[data-test="displayName"]').should('have.value', user.userId)
cy.get('input[data-test="displayName"]').clear()
cy.get('input[data-test="displayName"]').type('John Doe')
cy.get('input[data-test="displayName"]').should('have.value', 'John Doe')
})
// Success message is shown
cy.get('.toastify.toast-success').contains(/Display.+name.+was.+successfully.+changed/i).should('exist')
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
expect($result.exitCode).to.equal(0)
const info = JSON.parse($result.stdout)
expect(info?.display_name).to.equal('John Doe')
})
})
it('Can change the password', function() {
cy.visit('/settings/users')
openEditDialog(user)
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.get('input[data-test="password"]').should('have.value', '')
cy.get('input[data-test="password"]').type('newpassword123')
})
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify by logging in with the new password
cy.login(new User(user.userId, 'newpassword123'))
cy.visit('/apps/dashboard')
cy.url().should('include', '/apps/dashboard')
})
it('Can change the email address', function() {
// open the User settings as admin
cy.visit('/settings/users')
// toggle edit button into edit mode
toggleEditButton(user, true)
openEditDialog(user)
getUserListRow(user.userId).find('[data-cy-user-list-cell-email]').within(() => {
// see that the email of user is ""
cy.get('input').should('exist').and('have.value', '')
// set the email for user to mymail@example.com
cy.get('input').type('mymail@example.com')
// When I set the password for user to mymail@example.com
cy.get('input').should('have.value', 'mymail@example.com')
cy.get('input ~ button').click()
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
// see that the password cell for user is done loading
waitLoading('[data-cy-user-list-input-email]')
cy.get('.edit-dialog [data-test="form"]').within(() => {
cy.get('input[data-test="email"]').should('have.value', '')
cy.get('input[data-test="email"]').type('mymail@example.com')
cy.get('input[data-test="email"]').should('have.value', 'mymail@example.com')
})
// Success message is shown
cy.get('.toastify.toast-success').contains(/Email.+successfully.+changed/i).should('exist')
handlePasswordConfirmation(admin.password)
saveEditDialog()
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
expect($result.exitCode).to.equal(0)
const info = JSON.parse($result.stdout)
expect(info?.email).to.equal('mymail@example.com')
})
})
it('Can change the user quota to a predefined one', function() {
// open the User settings as admin
cy.visit('/settings/users')
// toggle edit button into edit mode
toggleEditButton(user, true)
openEditDialog(user)
getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').scrollIntoView()
getUserListRow(user.userId).find('[data-cy-user-list-cell-quota] [data-cy-user-list-input-quota]').within(() => {
// see that the quota of user is unlimited
cy.get('.vs__selected').should('exist').and('contain.text', 'Unlimited')
cy.get('.edit-dialog [data-test="form"]').within(() => {
// Open the quota selector
cy.get('[role="combobox"]').click({ force: true })
// see that there are default options for the quota
cy.get('li').then(($options) => {
expect($options).to.have.length(5)
cy.wrap($options).contains('Default quota')
cy.wrap($options).contains('Unlimited')
cy.wrap($options).contains('1 GB')
cy.wrap($options).contains('10 GB')
// select 5 GB
cy.wrap($options).contains('5 GB').click({ force: true })
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
})
// see that the quota of user is 5 GB
cy.get('.vs__selected').should('exist').and('contain.text', '5 GB')
cy.get('.vs__selected').contains('Unlimited').should('exist')
cy.findByRole('combobox', { name: /Quota/i }).click({ force: true })
})
// see that the changes are loading
waitLoading('[data-cy-user-list-input-quota]')
// Dropdown is floating outside the form — select 5 GB
cy.get('.vs__dropdown-menu').should('be.visible')
.contains('li', '5 GB').click({ force: true })
// finish editing the user
toggleEditButton(user, false)
handlePasswordConfirmation(admin.password)
saveEditDialog()
// I see that the quota was set on the backend
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
expect($result.exitCode).to.equal(0)
const info = JSON.parse($result.stdout)
@ -118,77 +119,53 @@ describe('Settings: Change user properties', function() {
})
it('Can change the user quota to a custom value', function() {
// open the User settings as admin
cy.visit('/settings/users')
// toggle edit button into edit mode
toggleEditButton(user, true)
openEditDialog(user)
getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').scrollIntoView()
getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').within(() => {
// see that the quota of user is unlimited
cy.get('.vs__selected').should('exist').and('contain.text', 'Unlimited')
// set the quota to 4 MB
cy.get('[data-cy-user-list-input-quota] input').type('4 MB{enter}')
// Make sure no confirmation modal is shown
handlePasswordConfirmation(admin.password)
// see that the quota of user is 4 MB
// TODO: Enable this after the file size handling is fixed
// cy.get('.vs__selected').should('exist').and('contain.text', '4 MB')
// see that the changes are loading
waitLoading('[data-cy-user-list-input-quota]')
cy.get('.edit-dialog [data-test="form"]').within(() => {
// Type a custom quota value
cy.findByRole('combobox', { name: /Quota/i }).type('4 MB{enter}')
})
// finish editing the user
toggleEditButton(user, false)
handlePasswordConfirmation(admin.password)
saveEditDialog()
// I see that the quota was set on the backend
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
expect($result.exitCode).to.equal(0)
// TODO: Enable this after the file size handling is fixed!!!!!!
// const info = JSON.parse($result.stdout)
// expect(info?.quota).to.equal('4 MB')
// Quota value is stored as bytes, verify it was set
const info = JSON.parse($result.stdout)
expect(info?.quota).to.not.equal('none')
})
})
it('Can make user a subadmin of a group', function() {
// create a group
const groupName = 'userstestgroup'
cy.runOccCommand(`group:add '${groupName}'`)
// open the User settings as admin
cy.visit('/settings/users')
// toggle edit button into edit mode
toggleEditButton(user, true)
openEditDialog(user)
getUserListRow(user.userId).find('[data-cy-user-list-cell-subadmins]').scrollIntoView()
getUserListRow(user.userId).find('[data-cy-user-list-cell-subadmins]').within(() => {
// see that the user is no subadmin
cy.get('.vs__selected').should('not.exist')
// Open the dropdown menu
cy.get('[role="combobox"]').click({ force: true })
// Search for the group
cy.get('[role="combobox"]').type('userstestgroup')
// select the group
cy.contains('li', groupName).click({ force: true })
// handle password confirmation on time out
handlePasswordConfirmation(admin.password)
// see that the user is subadmin of the group
cy.get('.vs__selected').should('exist').and('contain.text', groupName)
cy.get('.edit-dialog [data-test="form"]').within(() => {
// Find the subadmin NcSelect by its label and open the dropdown
cy.findByRole('combobox', { name: /Admin of the following groups/i }).click({ force: true })
cy.findByRole('combobox', { name: /Admin of the following groups/i }).type('userstestgroup')
})
waitLoading('[data-cy-user-list-input-subadmins]')
// Select the group from the floating dropdown
cy.get('.vs__dropdown-menu').should('be.visible')
.contains('li', groupName).click({ force: true })
// finish editing the user
toggleEditButton(user, false)
handlePasswordConfirmation(admin.password)
saveEditDialog()
// I see that the quota was set on the backend
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
// Verify backend
cy.getUserData(user).then(($response) => {
expect($response.status).to.equal(200)
const dom = (new DOMParser()).parseFromString($response.body, 'text/xml')