mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
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:
parent
48f4b0b956
commit
2bfdd86561
17 changed files with 960 additions and 446 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
331
apps/settings/src/components/Users/userFormUtils.spec.ts
Normal file
331
apps/settings/src/components/Users/userFormUtils.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
179
apps/settings/src/components/Users/userFormUtils.ts
Normal file
179
apps/settings/src/components/Users/userFormUtils.ts
Normal 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())
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue