Merge pull request #59406 from nextcloud/feat/40903/edit-user-dialog

feat(users and groups): re-use add account dialog when editing accounts
This commit is contained in:
Peter R. 2026-04-30 17:54:42 +02:00 committed by GitHub
commit 4d0cba89ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 2938 additions and 1491 deletions

View file

@ -39,6 +39,7 @@ return [
['root' => '/cloud', 'name' => 'Users#getEditableFieldsForUser', 'url' => '/user/fields/{userId}', 'verb' => 'GET'],
['root' => '/cloud', 'name' => 'Users#getEnabledApps', 'url' => '/user/apps', 'verb' => 'GET'],
['root' => '/cloud', 'name' => 'Users#editUser', 'url' => '/users/{userId}', 'verb' => 'PUT'],
['root' => '/cloud', 'name' => 'Users#editUserMultiField', 'url' => '/users/{userId}', 'verb' => 'PATCH'],
['root' => '/cloud', 'name' => 'Users#editUserMultiValue', 'url' => '/users/{userId}/{collectionName}', 'verb' => 'PUT', 'requirements' => ['collectionName' => '^(?!enable$|disable$)[a-zA-Z0-9_]*$']],
['root' => '/cloud', 'name' => 'Users#wipeUserDevices', 'url' => '/users/{userId}/wipe', 'verb' => 'POST'],
['root' => '/cloud', 'name' => 'Users#deleteUser', 'url' => '/users/{userId}', 'verb' => 'DELETE'],

View file

@ -36,6 +36,7 @@ use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IRootFolder;
use OCP\Group\ISubAdmin;
use OCP\HintException;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
@ -81,6 +82,7 @@ class UsersController extends AUserDataOCSController {
private IEventDispatcher $eventDispatcher,
private IPhoneNumberUtil $phoneNumberUtil,
private IAppManager $appManager,
private IAppConfig $appConfig,
) {
parent::__construct(
$appName,
@ -294,8 +296,9 @@ class UsersController extends AUserDataOCSController {
*
* 200: Users details returned based on last logged in information
*/
#[AuthorizedAdminSetting(settings:Users::class)]
public function getLastLoggedInUsers(string $search = '',
#[AuthorizedAdminSetting(settings: Users::class)]
public function getLastLoggedInUsers(
string $search = '',
?int $limit = null,
int $offset = 0,
): DataResponse {
@ -896,6 +899,277 @@ class UsersController extends AUserDataOCSController {
return new DataResponse();
}
/**
* Update multiple user account fields atomically.
* All submitted fields are validated first; if any fail, no changes are applied.
*
* Unlike editUser (which updates one field at a time via key/value),
* this method accepts named fields and applies them all in a single request.
*
* @param string $userId The user to update
* @param string|null $displayName New display name (null = no change)
* @param string|null $password New password (null = no change)
* @param string|null $email New primary email (null = no change, '' = clear)
* @param string|null $quota New quota e.g. "5 GB" (null = no change)
* @param string|null $language Language code e.g. "de" (null = no change)
* @param string|null $manager Manager user ID (null = no change, '' = clear)
* @param list<string>|null $groups Group IDs to assign (null = no change, [] = remove all)
* @param list<string>|null $subadminGroups Subadmin group IDs (null = no change, [] = remove all)
* @return DataResponse<Http::STATUS_OK, Provisioning_APIUserDetails, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, array{errors: array<string, string>}, array{}>
* @throws OCSException
*
* 200: User updated successfully
* 422: One or more submitted fields failed validation
*/
#[PasswordConfirmationRequired]
#[NoAdminRequired]
#[UserRateLimit(limit: 50, period: 600)]
public function editUserMultiField(
string $userId,
?string $displayName = null,
?string $password = null,
?string $email = null,
?string $quota = null,
?string $language = null,
?string $manager = null,
?array $groups = null,
?array $subadminGroups = null,
): DataResponse {
$currentLoggedInUser = $this->userSession->getUser();
if ($currentLoggedInUser === null) {
throw new OCSException('', OCSController::RESPOND_UNAUTHORISED);
}
$targetUser = $this->userManager->get($userId);
if ($targetUser === null) {
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
$isSelf = $targetUser->getUID() === $currentLoggedInUser->getUID();
$isAdmin = $this->groupManager->isAdmin($currentLoggedInUser->getUID());
$isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($currentLoggedInUser->getUID());
$subAdminManager = $this->groupManager->getSubAdmin();
$isSubAdminAccessible = !$isSelf && $subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser);
$canEditOther = $isAdmin
|| ($isDelegatedAdmin && !$this->groupManager->isAdmin($targetUser->getUID()))
|| $isSubAdminAccessible;
if (!$isSelf && !$canEditOther) {
// OCSForbiddenException used here (rather than the older OCSException pattern in editUser)
// because it is semantically correct: the caller is authenticated but lacks permission.
throw new OCSForbiddenException('Insufficient permissions to edit this user');
}
// Validate all submitted fields — collect errors before applying anything
$errors = [];
if ($displayName !== null) {
$backend = $targetUser->getBackend();
if (!$isSelf) {
$canSetDisplayName = $backend instanceof ISetDisplayNameBackend
|| ($backend !== null && $backend->implementsActions(Backend::SET_DISPLAYNAME));
} else {
$canSetDisplayName = $targetUser->canChangeDisplayName();
}
if (!$canSetDisplayName) {
$errors['displayName'] = $this->l10n->t('Cannot change display name for this user');
}
}
if ($password !== null) {
if (($error = $this->validatePasswordChange($targetUser, $password)) !== null) {
$errors['password'] = $error[0];
}
}
if ($email !== null && $email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors['email'] = $this->l10n->t('Invalid email address');
}
if ($language !== null) {
$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');
} elseif (!$this->l10nFactory->languageExists(null, $language)) {
$errors['language'] = $this->l10n->t('Invalid language');
}
}
if ($quota !== null) {
if (!$canEditOther) {
$errors['quota'] = $this->l10n->t('Insufficient permissions to change quota');
} else {
try {
$quota = $this->parseAndValidateQuota($quota);
} catch (\InvalidArgumentException $e) {
$errors['quota'] = $e->getMessage();
}
}
}
if ($groups !== null) {
if (!$isAdmin && !$isDelegatedAdmin) {
$errors['groups'] = $this->l10n->t('Insufficient permissions to change groups');
} else {
foreach ($groups as $gid) {
if (!$this->groupManager->groupExists($gid)) {
$errors['groups'] = $this->l10n->t('Group %s does not exist', [$gid]);
break;
}
}
}
}
if ($subadminGroups !== null) {
if (!$isAdmin && !$isDelegatedAdmin) {
$errors['subadminGroups'] = $this->l10n->t('Insufficient permissions to change sub-admin groups');
} else {
foreach ($subadminGroups as $gid) {
if (!$this->groupManager->groupExists($gid)) {
$errors['subadminGroups'] = $this->l10n->t('Group %s does not exist', [$gid]);
break;
}
}
}
}
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);
}
// Apply password first — it's the only setter that can fail at runtime
// (password policy plugins) after pre-flight validation passes.
// If it throws, no other fields have been touched yet.
if ($password !== null) {
try {
$targetUser->setPassword($password);
} catch (HintException $e) {
return new DataResponse(['errors' => ['password' => $e->getHint()]], Http::STATUS_UNPROCESSABLE_ENTITY);
}
}
// Apply remaining changes — all fully validated, setters won't throw
if ($displayName !== null) {
// OC\User\User::setDisplayName() rejects empty strings (!empty check),
// so "clear display name" means "reset to userId" — the default.
$targetUser->setDisplayName($displayName !== '' ? $displayName : $userId);
}
if ($email !== null) {
$targetUser->setSystemEMailAddress(mb_strtolower(trim($email)));
}
if ($quota !== null) {
$targetUser->setQuota($quota);
}
if ($language !== null) {
$this->config->setUserValue($targetUser->getUID(), 'core', 'lang', $language);
}
if ($manager !== null) {
$targetUser->setManagerUids(array_filter([$manager]));
}
if ($groups !== null) {
$currentGroups = $this->groupManager->getUserGroups($targetUser);
$currentGroupIds = array_map(fn (IGroup $g) => $g->getGID(), $currentGroups);
foreach (array_diff($currentGroupIds, $groups) as $gid) {
$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;
}
$this->groupManager->get($gid)?->addUser($targetUser);
}
}
if ($subadminGroups !== null) {
$currentSubAdminGroups = $subAdminManager->getSubAdminsGroups($targetUser);
$currentSubAdminGroupIds = array_map(fn (IGroup $g) => $g->getGID(), $currentSubAdminGroups);
foreach (array_diff($currentSubAdminGroupIds, $subadminGroups) as $gid) {
$group = $this->groupManager->get($gid);
if ($group !== null) {
$subAdminManager->deleteSubAdmin($targetUser, $group);
}
}
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);
}
}
}
/** @var Provisioning_APIUserDetails $data */
$data = $this->getUserData($userId);
return new DataResponse($data);
}
/**
* Validate and parse a quota string into the form expected by IUser::setQuota().
*
* Accepts: 'none', 'default', a numeric byte count, or a human-readable size like '5 GB'.
* Enforces max_quota and allow_unlimited_quota policies.
*
* @return string Parsed quota: 'none', 'default', or humanFileSize string (e.g. '5 GB')
* @throws \InvalidArgumentException With l10n'd user-facing error message
*/
private function parseAndValidateQuota(string $value): string {
if ($value === 'default') {
return 'default';
}
if ($value !== 'none') {
$bytes = is_numeric($value) ? (float)$value : Util::computerFileSize($value);
if ($bytes === false) {
throw new \InvalidArgumentException($this->l10n->t('Invalid quota value: %1$s', [$value]));
}
if ($bytes !== -1) {
$maxQuota = $this->appConfig->getValueInt('files', 'max_quota', -1);
if ($maxQuota !== -1 && $bytes > $maxQuota) {
throw new \InvalidArgumentException($this->l10n->t('Invalid quota value. %1$s is exceeding the maximum quota', [$value]));
}
return Util::humanFileSize($bytes);
}
}
$allowUnlimitedQuota = $this->appConfig->getValueString('files', 'allow_unlimited_quota', '1') === '1';
if (!$allowUnlimitedQuota) {
throw new \InvalidArgumentException($this->l10n->t('Unlimited quota is forbidden on this instance'));
}
return 'none';
}
/**
* Validate that a new password can be set on the target user.
*
* Does not apply the password only checks backend support and length.
*
* @return array{0: string, 1: int}|null Tuple of [error message, OCS code] or null on success.
* Code 112: backend not supported. Code 101: invalid value.
*/
private function validatePasswordChange(IUser $targetUser, string $password): ?array {
if (!$targetUser->canChangePassword()) {
return [$this->l10n->t('Setting the password is not supported by the users backend'), 112];
}
if (strlen($password) > IUserManager::MAX_PASSWORD_LENGTH) {
return [$this->l10n->t('Invalid password value'), 101];
}
return null;
}
/**
* @NoSubAdminRequired
*
@ -1024,32 +1298,10 @@ class UsersController extends AUserDataOCSController {
}
break;
case self::USER_FIELD_QUOTA:
$quota = $value;
if ($quota !== 'none' && $quota !== 'default') {
if (is_numeric($quota)) {
$quota = (float)$quota;
} else {
$quota = Util::computerFileSize($quota);
}
if ($quota === false) {
throw new OCSException($this->l10n->t('Invalid quota value: %1$s', [$value]), 101);
}
if ($quota === -1) {
$quota = 'none';
} else {
$maxQuota = (int)$this->config->getAppValue('files', 'max_quota', '-1');
if ($maxQuota !== -1 && $quota > $maxQuota) {
throw new OCSException($this->l10n->t('Invalid quota value. %1$s is exceeding the maximum quota', [$value]), 101);
}
$quota = Util::humanFileSize($quota);
}
}
// no else block because quota can be set to 'none' in previous if
if ($quota === 'none') {
$allowUnlimitedQuota = $this->config->getAppValue('files', 'allow_unlimited_quota', '1') === '1';
if (!$allowUnlimitedQuota) {
throw new OCSException($this->l10n->t('Unlimited quota is forbidden on this instance'), 101);
}
try {
$quota = $this->parseAndValidateQuota($value);
} catch (\InvalidArgumentException $e) {
throw new OCSException($e->getMessage(), 101);
}
$targetUser->setQuota($quota);
break;
@ -1058,11 +1310,8 @@ class UsersController extends AUserDataOCSController {
break;
case self::USER_FIELD_PASSWORD:
try {
if (strlen($value) > IUserManager::MAX_PASSWORD_LENGTH) {
throw new OCSException($this->l10n->t('Invalid password value'), 101);
}
if (!$targetUser->canChangePassword()) {
throw new OCSException($this->l10n->t('Setting the password is not supported by the users backend'), 112);
if (($error = $this->validatePasswordChange($targetUser, $value)) !== null) {
throw new OCSException($error[0], $error[1]);
}
$targetUser->setPassword($value);
} catch (HintException $e) { // password policy error
@ -1070,8 +1319,7 @@ class UsersController extends AUserDataOCSController {
}
break;
case self::USER_FIELD_LANGUAGE:
$languagesCodes = $this->l10nFactory->findAvailableLanguages();
if (!in_array($value, $languagesCodes, true) && $value !== 'en') {
if (!$this->l10nFactory->languageExists(null, $value)) {
throw new OCSException($this->l10n->t('Invalid language'), 101);
}
$this->config->setUserValue($targetUser->getUID(), 'core', 'lang', $value);
@ -1643,7 +1891,7 @@ class UsersController extends AUserDataOCSController {
*
* 200: User added as group subadmin successfully
*/
#[AuthorizedAdminSetting(settings:Users::class)]
#[AuthorizedAdminSetting(settings: Users::class)]
#[PasswordConfirmationRequired]
public function addSubAdmin(string $userId, string $groupid): DataResponse {
$group = $this->groupManager->get($groupid);
@ -1683,7 +1931,7 @@ class UsersController extends AUserDataOCSController {
*
* 200: User removed as group subadmin successfully
*/
#[AuthorizedAdminSetting(settings:Users::class)]
#[AuthorizedAdminSetting(settings: Users::class)]
#[PasswordConfirmationRequired]
public function removeSubAdmin(string $userId, string $groupid): DataResponse {
$group = $this->groupManager->get($groupid);
@ -1717,7 +1965,7 @@ class UsersController extends AUserDataOCSController {
*
* 200: User subadmin groups returned
*/
#[AuthorizedAdminSetting(settings:Users::class)]
#[AuthorizedAdminSetting(settings: Users::class)]
public function getUserSubAdminGroups(string $userId): DataResponse {
$groups = $this->getUserSubAdminGroupsData($userId);
return new DataResponse($groups);

View file

@ -4570,6 +4570,210 @@
}
}
},
"patch": {
"operationId": "users-edit-user-multi-field",
"summary": "Update multiple user account fields atomically. All submitted fields are validated first; if any fail, no changes are applied.",
"description": "Unlike editUser (which updates one field at a time via key/value), this method accepts named fields and applies them all in a single request.\nThis endpoint requires password confirmation",
"tags": [
"users"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"displayName": {
"type": "string",
"nullable": true,
"default": null,
"description": "New display name (null = no change)"
},
"password": {
"type": "string",
"nullable": true,
"default": null,
"description": "New password (null = no change)"
},
"email": {
"type": "string",
"nullable": true,
"default": null,
"description": "New primary email (null = no change, '' = clear)"
},
"quota": {
"type": "string",
"nullable": true,
"default": null,
"description": "New quota e.g. \"5 GB\" (null = no change)"
},
"language": {
"type": "string",
"nullable": true,
"default": null,
"description": "Language code e.g. \"de\" (null = no change)"
},
"manager": {
"type": "string",
"nullable": true,
"default": null,
"description": "Manager user ID (null = no change, '' = clear)"
},
"groups": {
"type": "array",
"nullable": true,
"default": null,
"description": "Group IDs to assign (null = no change, [] = remove all)",
"items": {
"type": "string"
}
},
"subadminGroups": {
"type": "array",
"nullable": true,
"default": null,
"description": "Subadmin group IDs (null = no change, [] = remove all)",
"items": {
"type": "string"
}
}
}
}
}
}
},
"parameters": [
{
"name": "userId",
"in": "path",
"description": "The user to update",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "User updated successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"$ref": "#/components/schemas/UserDetails"
}
}
}
}
}
}
}
},
"422": {
"description": "One or more submitted fields failed validation",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"errors"
],
"properties": {
"errors": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"delete": {
"operationId": "users-delete-user",
"summary": "Delete a user",

View file

@ -2081,6 +2081,210 @@
}
}
},
"patch": {
"operationId": "users-edit-user-multi-field",
"summary": "Update multiple user account fields atomically. All submitted fields are validated first; if any fail, no changes are applied.",
"description": "Unlike editUser (which updates one field at a time via key/value), this method accepts named fields and applies them all in a single request.\nThis endpoint requires password confirmation",
"tags": [
"users"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"displayName": {
"type": "string",
"nullable": true,
"default": null,
"description": "New display name (null = no change)"
},
"password": {
"type": "string",
"nullable": true,
"default": null,
"description": "New password (null = no change)"
},
"email": {
"type": "string",
"nullable": true,
"default": null,
"description": "New primary email (null = no change, '' = clear)"
},
"quota": {
"type": "string",
"nullable": true,
"default": null,
"description": "New quota e.g. \"5 GB\" (null = no change)"
},
"language": {
"type": "string",
"nullable": true,
"default": null,
"description": "Language code e.g. \"de\" (null = no change)"
},
"manager": {
"type": "string",
"nullable": true,
"default": null,
"description": "Manager user ID (null = no change, '' = clear)"
},
"groups": {
"type": "array",
"nullable": true,
"default": null,
"description": "Group IDs to assign (null = no change, [] = remove all)",
"items": {
"type": "string"
}
},
"subadminGroups": {
"type": "array",
"nullable": true,
"default": null,
"description": "Subadmin group IDs (null = no change, [] = remove all)",
"items": {
"type": "string"
}
}
}
}
}
}
},
"parameters": [
{
"name": "userId",
"in": "path",
"description": "The user to update",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "User updated successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"$ref": "#/components/schemas/UserDetails"
}
}
}
}
}
}
}
},
"422": {
"description": "One or more submitted fields failed validation",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"errors"
],
"properties": {
"errors": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"delete": {
"operationId": "users-delete-user",
"summary": "Delete a user",

View file

@ -21,11 +21,15 @@ use OCP\Accounts\IAccountManager;
use OCP\Accounts\IAccountProperty;
use OCP\Accounts\IAccountPropertyCollection;
use OCP\App\IAppManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\AppFramework\OCSController;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IRootFolder;
use OCP\Group\ISubAdmin;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IL10N;
@ -65,6 +69,7 @@ class UsersControllerTest extends TestCase {
private IRootFolder $rootFolder;
private IPhoneNumberUtil $phoneNumberUtil;
private IAppManager $appManager;
private IAppConfig&MockObject $appConfig;
protected function setUp(): void {
parent::setUp();
@ -86,6 +91,7 @@ class UsersControllerTest extends TestCase {
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
$this->phoneNumberUtil = new PhoneNumberUtil();
$this->appManager = $this->createMock(IAppManager::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->rootFolder = $this->createMock(IRootFolder::class);
$l10n = $this->createMock(IL10N::class);
@ -113,6 +119,7 @@ class UsersControllerTest extends TestCase {
$this->eventDispatcher,
$this->phoneNumberUtil,
$this->appManager,
$this->appConfig,
])
->onlyMethods(['fillStorageInfo'])
->getMock();
@ -502,6 +509,7 @@ class UsersControllerTest extends TestCase {
$this->eventDispatcher,
$this->phoneNumberUtil,
$this->appManager,
$this->appConfig,
])
->onlyMethods(['editUser'])
->getMock();
@ -2125,15 +2133,11 @@ class UsersControllerTest extends TestCase {
}
public function testEditUserAdminUserSelfEditChangeValidQuota(): void {
$this->config
$this->appConfig
->expects($this->once())
->method('getAppValue')
->willReturnCallback(function ($appid, $key, $default) {
if ($key === 'max_quota') {
return '-1';
}
return null;
});
->method('getValueInt')
->with('files', 'max_quota', -1)
->willReturn(-1);
$loggedInUser = $this->getMockBuilder(IUser::class)->disableOriginalConstructor()->getMock();
$loggedInUser
->expects($this->any())
@ -2213,15 +2217,11 @@ class UsersControllerTest extends TestCase {
}
public function testEditUserAdminUserEditChangeValidQuota(): void {
$this->config
$this->appConfig
->expects($this->once())
->method('getAppValue')
->willReturnCallback(function ($appid, $key, $default) {
if ($key === 'max_quota') {
return '-1';
}
return null;
});
->method('getValueInt')
->with('files', 'max_quota', -1)
->willReturn(-1);
$loggedInUser = $this->getMockBuilder(IUser::class)->disableOriginalConstructor()->getMock();
$loggedInUser
->expects($this->any())
@ -2268,8 +2268,8 @@ class UsersControllerTest extends TestCase {
public function testEditUserSelfEditChangeLanguage(): void {
$this->l10nFactory->expects($this->once())
->method('findAvailableLanguages')
->willReturn(['en', 'de', 'sv']);
->method('languageExists')
->willReturnCallback(fn ($app, $lang) => in_array($lang, ['en', 'de', 'sv'], true));
$this->config->expects($this->any())
->method('getSystemValue')
->willReturnMap([
@ -2370,8 +2370,8 @@ class UsersControllerTest extends TestCase {
public function testEditUserAdminEditChangeLanguage(): void {
$this->l10nFactory->expects($this->once())
->method('findAvailableLanguages')
->willReturn(['en', 'de', 'sv']);
->method('languageExists')
->willReturnCallback(fn ($app, $lang) => in_array($lang, ['en', 'de', 'sv'], true));
$loggedInUser = $this->createMock(IUser::class);
$loggedInUser
@ -2421,8 +2421,8 @@ class UsersControllerTest extends TestCase {
$this->l10nFactory->expects($this->once())
->method('findAvailableLanguages')
->willReturn(['en', 'de', 'sv']);
->method('languageExists')
->willReturnCallback(fn ($app, $lang) => in_array($lang, ['en', 'de', 'sv'], true));
$loggedInUser = $this->createMock(IUser::class);
$loggedInUser
@ -2466,15 +2466,11 @@ class UsersControllerTest extends TestCase {
}
public function testEditUserSubadminUserAccessible(): void {
$this->config
$this->appConfig
->expects($this->once())
->method('getAppValue')
->willReturnCallback(function ($appid, $key, $default) {
if ($key === 'max_quota') {
return '-1';
}
return null;
});
->method('getValueInt')
->with('files', 'max_quota', -1)
->willReturn(-1);
$loggedInUser = $this->getMockBuilder(IUser::class)->disableOriginalConstructor()->getMock();
$loggedInUser
->expects($this->any())
@ -2560,6 +2556,318 @@ class UsersControllerTest extends TestCase {
}
public function testUpdateUserAsAdminMultipleFields(): 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('canChangeDisplayName')->willReturn(true);
$backend = $this->createMock(UserInterface::class);
$backend->method('implementsActions')->willReturn(true);
$targetUser->method('getBackend')->willReturn($backend);
$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('isUserAccessible')->willReturn(false);
$this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
$targetUser->expects($this->once())->method('setDisplayName')->with('New Name')->willReturn(true);
$targetUser->expects($this->once())->method('setSystemEMailAddress')->with('new@example.com');
$result = $this->api->editUserMultiField('targetuser', displayName: 'New Name', email: 'new@example.com');
$this->assertInstanceOf(DataResponse::class, $result);
$this->assertSame(Http::STATUS_OK, $result->getStatus());
}
public function testUpdateUserValidationErrorsCollected(): 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');
$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);
$this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
$longPassword = str_repeat('a', 470);
$result = $this->api->editUserMultiField('targetuser', password: $longPassword, email: 'not-an-email');
$this->assertInstanceOf(DataResponse::class, $result);
$this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus());
$data = $result->getData();
$this->assertArrayHasKey('errors', $data);
$this->assertArrayHasKey('password', $data['errors']);
$this->assertArrayHasKey('email', $data['errors']);
}
public function testUpdateUserEmptyPayloadSucceeds(): 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);
$this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
$targetUser->expects($this->never())->method('setDisplayName');
$targetUser->expects($this->never())->method('setPassword');
$result = $this->api->editUserMultiField('targetuser');
$this->assertInstanceOf(DataResponse::class, $result);
$this->assertSame(Http::STATUS_OK, $result->getStatus());
}
public function testUpdateUserUnauthorized(): 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('anotheruser');
$this->userManager->method('get')->with('anotheruser')->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);
// editUserMultiField uses OCSForbiddenException (not OCSException) for
// permission failures — more semantically correct than the older editUser pattern.
$this->expectException(OCSForbiddenException::class);
$this->api->editUserMultiField('anotheruser', displayName: 'Hacked');
}
public function testUpdateUserNotFound(): void {
$currentUser = $this->createMock(IUser::class);
$currentUser->method('getUID')->willReturn('admin');
$this->userSession->method('getUser')->willReturn($currentUser);
$this->userManager->method('get')->with('ghost')->willReturn(null);
$this->expectExceptionCode(OCSController::RESPOND_NOT_FOUND);
$this->expectException(OCSException::class);
$this->api->editUserMultiField('ghost', displayName: 'Ghost');
}
public function testUpdateUserGroupDiff(): 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);
$this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
$oldGroup = $this->createMock(IGroup::class);
$oldGroup->method('getGID')->willReturn('oldgroup');
$newGroup = $this->createMock(IGroup::class);
$newGroup->method('getGID')->willReturn('newgroup');
$this->groupManager->method('getUserGroups')->willReturn([$oldGroup]);
$this->groupManager->method('groupExists')->willReturn(true);
$this->groupManager->method('get')->willReturnMap([
['newgroup', $newGroup],
['oldgroup', $oldGroup],
]);
$oldGroup->expects($this->once())->method('removeUser')->with($targetUser);
$newGroup->expects($this->once())->method('addUser')->with($targetUser);
$oldGroup->expects($this->never())->method('addUser');
$newGroup->expects($this->never())->method('removeUser');
$result = $this->api->editUserMultiField('targetuser', groups: ['newgroup']);
$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 testEditUserMultiFieldClearDisplayNameResetsToUserId(): 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('canChangeDisplayName')->willReturn(true);
$backend = $this->createMock(UserInterface::class);
$backend->method('implementsActions')->willReturn(true);
$targetUser->method('getBackend')->willReturn($backend);
$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);
$this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
// Clearing display name (empty string) should reset to userId
$targetUser->expects($this->once())->method('setDisplayName')->with('targetuser')->willReturn(true);
$result = $this->api->editUserMultiField('targetuser', displayName: '');
$this->assertSame(Http::STATUS_OK, $result->getStatus());
}
public function testDeleteUserNotExistingUser(): void {
$this->expectException(OCSException::class);
$this->expectExceptionCode(998);
@ -3842,6 +4150,7 @@ class UsersControllerTest extends TestCase {
$this->eventDispatcher,
$this->phoneNumberUtil,
$this->appManager,
$this->appConfig,
])
->onlyMethods(['getUserData'])
->getMock();
@ -3936,6 +4245,7 @@ class UsersControllerTest extends TestCase {
$this->eventDispatcher,
$this->phoneNumberUtil,
$this->appManager,
$this->appConfig,
])
->onlyMethods(['getUserData'])
->getMock();

View file

@ -10,9 +10,14 @@
:loading="loading"
:new-user="newUser"
:quota-options="quotaOptions"
@reset="resetForm"
@closing="closeDialog" />
<EditUserDialog
v-if="editingUser"
:user="editingUser"
:quota-options="quotaOptions"
@closing="editingUser = null" />
<NcEmptyContent
v-if="filteredUsers.length === 0"
class="empty"
@ -40,6 +45,7 @@
quotaOptions,
languages,
externalActions,
onEditUser: openEditDialog,
}"
@scroll-end="handleScrollEnd">
<template #before>
@ -64,11 +70,11 @@
<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'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import EditUserDialog from './Users/EditUserDialog.vue'
import NewUserDialog from './Users/NewUserDialog.vue'
import UserListFooter from './Users/UserListFooter.vue'
import UserListHeader from './Users/UserListHeader.vue'
@ -78,13 +84,13 @@ import logger from '../logger.ts'
import { defaultQuota, unlimitedQuota } from '../utils/userUtils.ts'
const newUser = Object.freeze({
id: '',
username: '',
displayName: '',
password: '',
mailAddress: '',
email: '',
groups: [],
manager: '',
subAdminsGroups: [],
subadminGroups: [],
quota: defaultQuota,
language: {
code: 'en',
@ -96,6 +102,7 @@ export default {
name: 'UserList',
components: {
EditUserDialog,
Fragment,
NcEmptyContent,
NcIconSvgWrapper,
@ -137,6 +144,7 @@ export default {
},
newUser: { ...newUser },
editingUser: null,
isInitialLoad: true,
}
},
@ -258,7 +266,7 @@ export default {
/**
* Reset and init new user form
*/
this.resetForm()
this.initForm()
/**
* If disabled group but empty, redirect
@ -267,6 +275,10 @@ export default {
},
methods: {
openEditDialog(user) {
this.editingUser = user
},
async handleScrollEnd() {
await this.loadUsers()
},
@ -308,27 +320,35 @@ export default {
key: 'showNewUserForm',
value: false,
})
this.resetForm()
},
/**
* Reset the new user form to its initial state.
* Uses in-place mutation (Object.assign + splice) so the
* provide/inject reference stays intact.
*/
resetForm() {
// revert form to original state
this.newUser = { ...newUser }
Object.assign(this.newUser, {
...newUser,
groups: [],
subadminGroups: [],
})
this.newUser.groups.splice(0)
this.newUser.subadminGroups.splice(0)
this.initForm()
},
initForm() {
/**
* 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
*/
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

@ -0,0 +1,155 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog
class="edit-dialog"
size="small"
:name="t('settings', 'Edit account')"
outTransition
@closing="$emit('closing')">
<form
id="edit-user-form"
class="edit-dialog__form"
data-test="form"
:disabled="saving"
@submit.prevent="save">
<UserFormFields
:fieldConfig="fieldConfig"
:errors="fieldErrors"
:quotaOptions="quotaOptions" />
</form>
<template #actions>
<NcButton
class="edit-dialog__submit"
data-test="submit"
form="edit-user-form"
variant="primary"
type="submit"
:disabled="saving">
{{ saving ? t('settings', 'Saving\u00A0…') : t('settings', 'Save') }}
</NcButton>
</template>
</NcDialog>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { 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 { diffPayload, userToFormData } from './userFormUtils.ts'
export default {
name: 'EditUserDialog',
components: {
NcButton,
NcDialog,
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,
required: true,
},
quotaOptions: {
type: Array,
required: true,
},
},
emits: ['closing'],
data() {
const allGroups = this.$store.getters.getGroups
const serverLanguages = this.$store.getters.getServerData.languages
const formData = userToFormData(this.user, allGroups, this.quotaOptions, serverLanguages)
return {
/** Snapshot of initial state for diffing */
initialData: structuredClone(formData),
/** Mutable form state */
editedUser: formData,
saving: false,
fieldErrors: {},
}
},
computed: {
settings() {
return this.$store.getters.getServerData
},
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'),
},
}
},
},
methods: {
async save() {
this.fieldErrors = {}
const payload = diffPayload(this.initialData, this.editedUser)
if (Object.keys(payload).length === 0) {
this.$emit('closing')
return
}
this.saving = true
try {
await confirmPassword()
await this.$store.dispatch('editUserMultiField', {
userid: this.user.id,
payload,
})
showSuccess(t('settings', 'Account updated'))
this.$emit('closing')
} catch (error) {
const errors = error.response?.data?.ocs?.data?.errors
if (errors && typeof errors === 'object') {
this.fieldErrors = errors
} else {
logger.error('Failed to update account', { error })
showError(t('settings', 'Failed to update account'))
}
} finally {
this.saving = false
}
},
},
}
</script>
<style lang="scss" scoped>
.edit-dialog {
:deep(.dialog__actions) {
margin-block-start: calc(var(--default-grid-baseline, 4px) * 3);
}
}
</style>

View file

@ -16,128 +16,10 @@
data-test="form"
:disabled="loading.all"
@submit.prevent="createUser">
<NcTextField
ref="username"
v-model="newUser.id"
class="dialog__item"
data-test="username"
:disabled="settings.newUserGenerateUserID"
:label="usernameLabel"
autocapitalize="none"
autocomplete="off"
spellcheck="false"
pattern="[a-zA-Z0-9 _\.@\-']+"
required />
<NcTextField
v-model="newUser.displayName"
class="dialog__item"
data-test="displayName"
:label="t('settings', 'Display name')"
autocapitalize="none"
autocomplete="off"
spellcheck="false" />
<span
v-if="!settings.newUserRequireEmail"
id="password-email-hint"
class="dialog__hint">
{{ t('settings', 'Either password or email is required') }}
</span>
<NcPasswordField
ref="password"
v-model="newUser.password"
class="dialog__item"
data-test="password"
:minlength="minPasswordLength"
:maxlength="469"
aria-describedby="password-email-hint"
:label="newUser.mailAddress === '' ? t('settings', 'Password (required)') : t('settings', 'Password')"
autocapitalize="none"
autocomplete="new-password"
spellcheck="false"
:required="newUser.mailAddress === ''" />
<NcTextField
v-model="newUser.mailAddress"
class="dialog__item"
data-test="email"
type="email"
aria-describedby="password-email-hint"
:label="newUser.password === '' || settings.newUserRequireEmail ? t('settings', 'Email (required)') : t('settings', 'Email')"
autocapitalize="none"
autocomplete="off"
spellcheck="false"
:required="newUser.password === '' || settings.newUserRequireEmail" />
<div class="dialog__item">
<NcSelect
class="dialog__select"
data-test="groups"
:input-label="!settings.isAdmin && !settings.isDelegatedAdmin ? t('settings', 'Member of the following groups (required)') : t('settings', 'Member of the following groups')"
:placeholder="t('settings', 'Set account groups')"
:disabled="loading.groups || loading.all"
:options="availableGroups"
:model-value="newUser.groups"
label="name"
keep-open
:multiple="true"
:taggable="settings.isAdmin || settings.isDelegatedAdmin"
:required="!settings.isAdmin && !settings.isDelegatedAdmin"
:create-option="(value) => ({ id: value, name: value, isCreating: true })"
@search="searchGroups"
@option:created="createGroup"
@option:deselected="removeGroup"
@option:selected="options => addGroup(options.at(-1))" />
<!-- If user is not admin, they are a subadmin.
Subadmins can't create users outside their groups
Therefore, empty select is forbidden -->
</div>
<div class="dialog__item">
<NcSelect
v-model="newUser.subAdminsGroups"
class="dialog__select"
:input-label="t('settings', 'Admin of the following groups')"
:placeholder="t('settings', 'Set account as admin for …')"
:disabled="loading.groups || loading.all"
:options="availableSubAdminGroups"
keep-open
:multiple="true"
label="name"
@search="searchGroups" />
</div>
<div class="dialog__item">
<NcSelect
v-model="newUser.quota"
class="dialog__select"
:input-label="t('settings', 'Quota')"
:placeholder="t('settings', 'Set account quota')"
:options="quotaOptions"
:clearable="false"
:taggable="true"
:create-option="validateQuota" />
</div>
<div
v-if="showConfig.showLanguages"
class="dialog__item">
<NcSelect
v-model="newUser.language"
class="dialog__select"
:input-label="t('settings', 'Language')"
:placeholder="t('settings', 'Set default language')"
:clearable="false"
:selectable="option => !option.languages"
:filter-by="languageFilterBy"
:options="languages"
label="name" />
</div>
<div class="dialog__item dialog__managers" :class="[{ 'icon-loading-small': loading.manager }]">
<NcSelect
v-model="newUser.manager"
class="dialog__select"
:input-label="managerInputLabel"
:placeholder="managerLabel"
:options="possibleManagers"
:user-select="true"
label="displayname"
@search="searchUserManager" />
</div>
<UserFormFields
ref="fields"
:field-config="fieldConfig"
:quota-options="quotaOptions" />
</form>
<template #actions>
@ -154,14 +36,9 @@
</template>
<script>
import { formatFileSize, parseFileSize } from '@nextcloud/files'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import logger from '../../logger.ts'
import { searchGroups } from '../../service/groups.ts'
import UserFormFields from './UserFormFields.vue'
export default {
name: 'NewUserDialog',
@ -169,9 +46,15 @@ export default {
components: {
NcButton,
NcDialog,
NcPasswordField,
NcSelect,
NcTextField,
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: {
@ -191,23 +74,9 @@ export default {
},
},
data() {
return {
possibleManagers: [],
// TRANSLATORS This string describes a manager in the context of an organization
managerInputLabel: t('settings', 'Manager'),
// TRANSLATORS This string describes a manager in the context of an organization
managerLabel: t('settings', 'Set line manager'),
// Cancelable promise for search groups request
promise: null,
}
},
emits: ['closing'],
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
settings() {
return this.$store.getters.getServerData
},
@ -219,44 +88,43 @@ export default {
return t('settings', 'Account name (required)')
},
minPasswordLength() {
return this.$store.getters.getPasswordPolicyMinLength
},
availableGroups() {
const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin)
? this.$store.getters.getSortedGroups
: this.$store.getters.getSubAdminGroups
return groups.filter((group) => group.id !== '__nc_internal_recent' && group.id !== 'disabled')
},
availableSubAdminGroups() {
return this.availableGroups.filter((group) => group.id !== 'admin')
},
languages() {
return [
{
name: t('settings', 'Common languages'),
languages: this.settings.languages.commonLanguages,
/**
* Reactive field configuration passed to UserFormFields.
* Controls visibility, labels, and required state for each field
* based on the current form values and server settings.
*/
fieldConfig() {
return {
username: {
show: true,
label: this.usernameLabel,
disabled: this.settings.newUserGenerateUserID,
required: true,
},
...this.settings.languages.commonLanguages,
{
name: t('settings', 'Other languages'),
languages: this.settings.languages.otherLanguages,
},
...this.settings.languages.otherLanguages,
]
},
},
async beforeMount() {
await this.searchUserManager()
password: {
label: this.newUser.email === ''
? t('settings', 'Password (required)')
: t('settings', 'Password'),
required: this.newUser.email === '',
},
email: {
label: this.newUser.password === '' || this.settings.newUserRequireEmail
? t('settings', 'Email (required)')
: t('settings', 'Email'),
required: this.newUser.password === '' || this.settings.newUserRequireEmail,
},
showPasswordEmailHint: !this.settings.newUserRequireEmail,
}
},
},
mounted() {
this.$refs.username?.focus?.()
this.$refs.fields?.focusField('username')
},
methods: {
@ -264,201 +132,38 @@ export default {
this.loading.all = true
try {
await this.$store.dispatch('addUser', {
userid: this.newUser.id,
userid: this.newUser.username,
password: this.newUser.password,
displayName: this.newUser.displayName,
email: this.newUser.mailAddress,
groups: this.newUser.groups.map((group) => group.id),
subadmin: this.newUser.subAdminsGroups.map((group) => group.id),
email: this.newUser.email,
groups: this.newUser.groups.map(({ id }) => id),
subadmin: this.newUser.subadminGroups.map(({ id }) => id),
quota: this.newUser.quota.id,
language: this.newUser.language.code,
manager: this.newUser.manager.id,
})
this.$emit('reset')
this.$refs.username?.focus?.()
this.$emit('closing')
} catch (error) {
this.loading.all = false
if (error.response && error.response.data && error.response.data.ocs && error.response.data.ocs.meta) {
if (error.response?.data?.ocs?.meta) {
const statuscode = error.response.data.ocs.meta.statuscode
if (statuscode === 102) {
// wrong username
this.$refs.username?.focus?.()
this.$refs.fields?.focusField('username')
} else if (statuscode === 107) {
// wrong password
this.$refs.password?.focus?.()
this.$refs.fields?.focusField('password')
}
}
}
},
async searchGroups(query, toggleLoading) {
if (!this.settings.isAdmin && !this.settings.isDelegatedAdmin) {
// managers cannot search for groups
return
}
if (this.promise) {
this.promise.cancel()
}
toggleLoading(true)
try {
this.promise = searchGroups({
search: query,
offset: 0,
limit: 25,
})
const groups = await this.promise
// Populate store from server request
for (const group of groups) {
this.$store.commit('addGroup', group)
}
} catch (error) {
logger.error(t('settings', 'Failed to search groups'), { error })
}
this.promise = null
toggleLoading(false)
},
/**
* Create a new group
*
* @param {any} group Group
* @param {string} group.name Group id
*/
async createGroup({ name: gid }) {
this.loading.groups = true
try {
await this.$store.dispatch('addGroup', gid)
this.newUser.groups.push({ id: gid, name: gid })
} catch (error) {
logger.error(t('settings', 'Failed to create group'), { error })
}
this.loading.groups = false
},
/**
* Add user to group
*
* @param {object} group Group object
*/
async addGroup(group) {
if (group.isCreating) {
return
}
if (group.canAdd === false) {
return
}
this.newUser.groups.push(group)
},
/**
* Remove user from group
*
* @param {object} group Group object
*/
removeGroup(group) {
if (group.canRemove === false) {
return
}
this.newUser.groups = this.newUser.groups.filter((g) => g.id !== group.id)
},
/**
* Validate quota string to make sure it's a valid human file size
*
* @param {string} quota Quota in readable format '5 GB'
* @return {object}
*/
validateQuota(quota) {
// only used for new presets sent through @Tag
const validQuota = OC.Util.computerFileSize(quota)
if (validQuota !== null && validQuota >= 0) {
// unify format output
quota = formatFileSize(parseFileSize(quota, true))
this.newUser.quota = { id: quota, label: quota }
return this.newUser.quota
}
// Default is unlimited
this.newUser.quota = this.quotaOptions[0]
return this.quotaOptions[0]
},
languageFilterBy(option, label, search) {
// Show group header of the language
if (option.languages) {
return option.languages.some(({ name }) => name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
}
return (label || '').toLocaleLowerCase().includes(search.toLocaleLowerCase())
},
async searchUserManager(query) {
await this.$store.dispatch(
'searchUsers',
{
offset: 0,
limit: 10,
search: query,
},
).then((response) => {
const users = response?.data ? Object.values(response?.data.ocs.data.users) : []
if (users.length > 0) {
this.possibleManagers = users
}
})
},
},
}
</script>
<style lang="scss" scoped>
.dialog {
&__form {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 8px;
gap: 4px 0;
}
&__item {
width: 100%;
&:not(:focus):not(:active) {
border-color: var(--color-border-dark);
}
}
&__hint {
color: var(--color-text-maxcontrast);
margin-top: 8px;
align-self: flex-start;
}
&__label {
display: block;
padding: 4px 0;
}
&__select {
width: 100%;
}
&__managers {
margin-bottom: 12px;
}
&__submit {
margin-top: 4px;
margin-bottom: 8px;
}
:deep {
.dialog__actions {
margin: auto;
}
:deep(.dialog__actions) {
margin-block-start: calc(var(--default-grid-baseline, 4px) * 3);
}
}
</style>

View file

@ -0,0 +1,249 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="user-form-fields">
<!-- Static display for non-editable username (edit dialog) -->
<div
v-if="fieldConfig.username?.show && fieldConfig.username?.disabled"
class="user-form-fields__item user-form-fields__static"
data-test="username">
<span class="user-form-fields__static-label">
{{ fieldConfig.username?.label }}
</span>
<span class="user-form-fields__static-value">
{{ formData.username }}
</span>
</div>
<!-- Editable username input (create dialog) -->
<NcTextField
v-else-if="fieldConfig.username?.show"
ref="username"
v-model="formData.username"
class="user-form-fields__item"
data-test="username"
:label="fieldConfig.username?.label"
autocapitalize="none"
autocomplete="off"
spellcheck="false"
pattern="[a-zA-Z0-9 _\.@\-']+"
:required="fieldConfig.username?.required" />
<NcTextField
v-model="formData.displayName"
class="user-form-fields__item"
data-test="displayName"
:label="t('settings', 'Display name')"
:error="!!errors.displayName"
:helper-text="errors.displayName"
autocapitalize="none"
autocomplete="off"
spellcheck="false" />
<span
v-if="fieldConfig.showPasswordEmailHint"
id="password-email-hint"
class="user-form-fields__hint">
{{ t('settings', 'Either password or email is required') }}
</span>
<NcPasswordField
v-if="fieldConfig.password?.show !== false"
ref="password"
v-model="formData.password"
class="user-form-fields__item"
data-test="password"
:minlength="minPasswordLength"
:maxlength="469"
:aria-describedby="fieldConfig.showPasswordEmailHint ? 'password-email-hint' : undefined"
:label="fieldConfig.password?.label"
:error="!!errors.password"
:helper-text="errors.password"
autocapitalize="none"
autocomplete="new-password"
spellcheck="false"
:required="fieldConfig.password?.required" />
<NcTextField
v-model="formData.email"
class="user-form-fields__item"
data-test="email"
type="email"
:aria-describedby="fieldConfig.showPasswordEmailHint ? 'password-email-hint' : undefined"
:label="fieldConfig.email?.label || t('settings', 'Email')"
:error="!!errors.email"
:helper-text="errors.email"
autocapitalize="none"
autocomplete="off"
spellcheck="false"
:required="fieldConfig.email?.required" />
<UserFormGroups />
<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>
<script>
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import UserFormGroups from './UserFormGroups.vue'
import UserFormLanguage from './UserFormLanguage.vue'
import UserFormManager from './UserFormManager.vue'
import UserFormQuota from './UserFormQuota.vue'
/**
* Shared form fields for creating and editing user accounts.
*
* 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 inject the same formData.
*
* Expected formData shape:
* { username, displayName, password, email, groups, subadminGroups, quota, language, manager }
*/
export default {
name: 'UserFormFields',
components: {
NcPasswordField,
NcTextField,
UserFormGroups,
UserFormLanguage,
UserFormManager,
UserFormQuota,
},
inject: ['formData'],
props: {
/** Quota preset options for the quota select */
quotaOptions: {
type: Array,
required: true,
},
/**
* Per-field configuration for visibility, labels, and required state.
* Only fields that differ from defaults need to be specified.
*
* Example: { username: { show: true, label: 'Account name', required: true },
* password: { show: true, label: 'Password', required: false },
* email: { label: 'Email (required)', required: true },
* showPasswordEmailHint: true }
*/
fieldConfig: {
type: Object,
default: () => ({}),
},
/** Per-field error messages from 422 validation (e.g. { email: 'Invalid' }) */
errors: {
type: Object,
default: () => ({}),
},
},
computed: {
minPasswordLength() {
return this.$store.getters.getPasswordPolicyMinLength
},
unhandledErrors() {
const handled = new Set(['displayName', 'password', 'email'])
return Object.fromEntries(Object.entries(this.errors).filter(([key]) => !handled.has(key)))
},
},
methods: {
focusField(name) {
this.$refs[name]?.focus?.()
},
},
}
</script>
<style lang="scss" scoped>
.user-form-fields {
display: flex;
flex-direction: column;
align-items: center;
gap: calc(var(--default-grid-baseline, 4px) * 2) 0;
&__item {
width: 100%;
&:not(:focus):not(:active) {
border-color: var(--color-border-dark);
}
}
&__static {
display: flex;
flex-direction: column;
justify-content: center;
min-height: var(--default-clickable-area, 44px);
padding: var(--border-width-input-focused, 2px);
padding-inline: calc(var(--border-radius-element, 8px) + var(--border-width-input-focused, 2px));
// Manually align static value with inputs below until we have a static field in component lib.
// See: https://github.com/nextcloud/server/issues/53862#issuecomment-4212613996
margin-left: 18px;
&-label {
font-size: var(--font-size-small, 13px);
font-weight: 500;
line-height: 1.5;
color: var(--color-text-maxcontrast);
}
&-value {
font-size: var(--default-font-size, 14px);
line-height: 1.5;
color: var(--color-main-text);
}
}
&__hint {
color: var(--color-text-maxcontrast);
margin-block-start: calc(var(--default-grid-baseline, 4px) * 2);
align-self: flex-start;
}
// Reach into sub-component root elements to apply consistent sizing
:deep(.user-form__item) {
width: 100%;
}
:deep(.user-form__select) {
width: 100%;
}
&__error-summary {
width: 100%;
margin-block-start: calc(var(--default-grid-baseline, 4px) * 2);
color: var(--color-error);
font-size: var(--default-font-size, 14px);
p {
margin-block: calc(var(--default-grid-baseline, 4px) / 2);
}
}
}
</style>

View file

@ -0,0 +1,141 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="user-form-groups">
<div class="user-form__item">
<NcSelect
v-model="formData.groups"
class="user-form__select"
data-test="groups"
:input-label="groupsLabel"
:placeholder="t('settings', 'Set account groups')"
:disabled="creatingGroup"
:options="availableGroups"
label="name"
keep-open
:multiple="true"
:taggable="settings.isAdmin || settings.isDelegatedAdmin"
:required="!settings.isAdmin && !settings.isDelegatedAdmin"
:create-option="(value) => ({ id: value, name: value, isCreating: true })"
@search="searchGroups"
@option:created="createGroup" />
</div>
<div
v-if="settings.isAdmin || settings.isDelegatedAdmin"
class="user-form__item">
<NcSelect
v-model="formData.subadminGroups"
class="user-form__select"
:input-label="t('settings', 'Admin of the following groups')"
:placeholder="t('settings', 'Set account as admin for …')"
:disabled="creatingGroup"
:options="availableSubAdminGroups"
keep-open
:multiple="true"
label="name"
@search="searchGroups" />
</div>
</div>
</template>
<script>
import NcSelect from '@nextcloud/vue/components/NcSelect'
import logger from '../../logger.ts'
import { searchGroups } from '../../service/groups.ts'
export default {
name: 'UserFormGroups',
components: {
NcSelect,
},
inject: ['formData'],
data() {
return {
creatingGroup: false,
promise: null,
}
},
computed: {
settings() {
return this.$store.getters.getServerData
},
availableGroups() {
const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin)
? this.$store.getters.getSortedGroups
: this.$store.getters.getSubAdminGroups
return groups.filter(({ id }) => id !== '__nc_internal_recent' && id !== 'disabled')
},
availableSubAdminGroups() {
return this.availableGroups.filter(({ id }) => id !== 'admin')
},
groupsLabel() {
return !this.settings.isAdmin && !this.settings.isDelegatedAdmin
? t('settings', 'Member of the following groups (required)')
: t('settings', 'Member of the following groups')
},
},
methods: {
async searchGroups(query, toggleLoading) {
if (!this.settings.isAdmin && !this.settings.isDelegatedAdmin) {
return
}
if (this.promise) {
this.promise.cancel()
}
toggleLoading(true)
try {
this.promise = searchGroups({ search: query, offset: 0, limit: 25 })
const groups = await this.promise
for (const group of groups) {
this.$store.commit('addGroup', group)
}
} catch (error) {
logger.error(t('settings', 'Failed to search groups'), { error })
}
this.promise = null
toggleLoading(false)
},
async createGroup({ name: gid }) {
this.creatingGroup = true
try {
await this.$store.dispatch('addGroup', gid)
this.formData.groups.push({ id: gid, name: gid })
} catch (error) {
logger.error(t('settings', 'Failed to create group'), { error })
}
this.creatingGroup = false
},
},
}
</script>
<style lang="scss" scoped>
.user-form-groups {
display: flex;
flex-direction: column;
gap: calc(var(--default-grid-baseline, 4px) * 2) 0;
width: 100%;
}
.user-form__item {
width: 100%;
}
.user-form__select {
width: 100%;
}
</style>

View file

@ -0,0 +1,56 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
v-if="showConfig.showLanguages"
class="user-form__item">
<NcSelect
v-model="formData.language"
class="user-form__select"
:input-label="t('settings', 'Language')"
:placeholder="t('settings', 'Set default language')"
:clearable="false"
:selectable="option => !option.languages"
:filter-by="languageFilterBy"
:options="languages"
label="name" />
</div>
</template>
<script>
import NcSelect from '@nextcloud/vue/components/NcSelect'
import { languageFilterBy } from './userFormUtils.ts'
export default {
name: 'UserFormLanguage',
components: {
NcSelect,
},
inject: ['formData'],
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
languages() {
const { commonLanguages, otherLanguages } = this.$store.getters.getServerData.languages
return [
{ name: t('settings', 'Common languages'), languages: commonLanguages },
...commonLanguages,
{ name: t('settings', 'Other languages'), languages: otherLanguages },
...otherLanguages,
]
},
},
methods: {
languageFilterBy,
},
}
</script>

View file

@ -0,0 +1,109 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="user-form__item user-form__managers">
<NcSelectUsers
:modelValue="managerModel"
class="user-form__select"
:input-label="t('settings', 'Manager')"
:placeholder="t('settings', 'Search for a manager…')"
:options="managerOptions"
:loading="loading"
@update:modelValue="onManagerChange"
@search="searchUserManager" />
</div>
</template>
<script>
import NcSelectUsers from '@nextcloud/vue/components/NcSelectUsers'
import logger from '../../logger.ts'
export default {
name: 'UserFormManager',
components: {
NcSelectUsers,
},
inject: ['formData'],
data() {
return {
possibleManagers: [],
loading: false,
searchTimeout: null,
}
},
computed: {
/**
* Map internal formData.manager to NcSelectUsersModel shape.
* Cached to keep object identity stable across reads, so NcSelectUsers
* doesn't see a fresh :modelValue on every parent re-render.
*/
managerModel() {
const m = this.formData.manager
if (!m) {
return null
}
const id = typeof m === 'object' ? m.id : m
const displayName = typeof m === 'object' ? (m.displayname ?? m.id) : m
if (this._managerModelCache?.id === id && this._managerModelCache?.displayName === displayName) {
return this._managerModelCache
}
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this._managerModelCache = { id, displayName }
return this._managerModelCache
},
/** Map API users to NcSelectUsersModel shape */
managerOptions() {
return this.possibleManagers.map((u) => ({
id: u.id,
displayName: u.displayname ?? u.id,
subname: u.email ?? '',
}))
},
},
beforeUnmount() {
clearTimeout(this.searchTimeout)
},
methods: {
/** Map NcSelectUsersModel back to internal formData shape */
onManagerChange(value) {
this.formData.manager = value
? { id: value.id, displayname: value.displayName }
: ''
},
/** Debounce keystrokes so a 10-char query produces 1-2 requests, not 10. */
searchUserManager(query) {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => this.fetchManagers(query), 200)
},
async fetchManagers(query) {
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
}
},
},
}
</script>

View file

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

View file

@ -20,26 +20,8 @@
</td>
<td class="row__cell row__cell--displayname" data-cy-user-list-cell-displayname>
<template v-if="editing && user.backendCapabilities.setDisplayName">
<NcTextField
ref="displayNameField"
v-model="editedDisplayName"
class="user-row-text-field"
data-cy-user-list-input-displayname
:data-loading="loading.displayName || undefined"
:trailing-button-label="t('settings', 'Submit')"
:class="{ 'icon-loading-small': loading.displayName }"
:show-trailing-button="true"
:disabled="loading.displayName || isLoadingField"
:label="t('settings', 'Change display name')"
trailing-button-icon="arrowEnd"
autocapitalize="off"
autocomplete="off"
spellcheck="false"
@trailing-button-click="updateDisplayName" />
</template>
<strong
v-else-if="!isObfuscated"
v-if="!isObfuscated"
:title="user.displayname?.length > 20 ? user.displayname : null">
{{ user.displayname }}
</strong>
@ -50,61 +32,16 @@
</td>
<td class="row__cell" data-cy-user-list-cell-email>
<template v-if="editing">
<NcTextField
v-model="editedMail"
class="user-row-text-field"
:class="{ 'icon-loading-small': loading.mailAddress }"
data-cy-user-list-input-email
:data-loading="loading.mailAddress || undefined"
:show-trailing-button="true"
:trailing-button-label="t('settings', 'Submit')"
:label="t('settings', 'Set new email address')"
:disabled="loading.mailAddress || isLoadingField"
trailing-button-icon="arrowEnd"
autocapitalize="off"
autocomplete="email"
spellcheck="false"
type="email"
@trailing-button-click="updateEmail" />
</template>
<span
v-else-if="!isObfuscated"
v-if="!isObfuscated"
:title="user.email?.length > 20 ? user.email : null">
{{ user.email }}
</span>
</td>
<td class="row__cell row__cell--groups row__cell--multiline" data-cy-user-list-cell-groups>
<template v-if="editing">
<label
class="hidden-visually"
:for="'groups' + uniqueId">
{{ t('settings', 'Add account to group') }}
</label>
<NcSelect
data-cy-user-list-input-groups
:data-loading="loading.groups || undefined"
:input-id="'groups' + uniqueId"
keep-open
:disabled="isLoadingField || loading.groupsDetails"
:loading="loading.groups"
:multiple="true"
:append-to-body="false"
:options="availableGroups"
:placeholder="t('settings', 'Add account to group')"
:taggable="settings.isAdmin || settings.isDelegatedAdmin"
:model-value="userGroups"
label="name"
:no-wrap="true"
:create-option="(value) => ({ id: value, name: value, isCreating: true })"
@search="searchGroups"
@option:created="createGroup"
@option:selected="options => addUserGroup(options.at(-1))"
@option:deselected="removeUserGroup" />
</template>
<span
v-else-if="!isObfuscated"
v-if="!isObfuscated"
:title="userGroupsLabels?.length > 40 ? userGroupsLabels : null">
{{ userGroupsLabels }}
</span>
@ -114,60 +51,15 @@
v-if="settings.isAdmin || settings.isDelegatedAdmin"
data-cy-user-list-cell-subadmins
class="row__cell row__cell--large row__cell--multiline">
<template v-if="editing && (settings.isAdmin || settings.isDelegatedAdmin)">
<label
class="hidden-visually"
:for="'subadmins' + uniqueId">
{{ t('settings', 'Set account as admin for') }}
</label>
<NcSelect
data-cy-user-list-input-subadmins
:data-loading="loading.subadmins || undefined"
:input-id="'subadmins' + uniqueId"
keep-open
:disabled="isLoadingField || loading.subAdminGroupsDetails"
:loading="loading.subadmins"
label="name"
:append-to-body="false"
:multiple="true"
:no-wrap="true"
:options="availableSubAdminGroups"
:placeholder="t('settings', 'Set account as admin for')"
:model-value="userSubAdminGroups"
@search="searchGroups"
@option:deselected="removeUserSubAdmin"
@option:selected="options => addUserSubAdmin(options.at(-1))" />
</template>
<span
v-else-if="!isObfuscated"
v-if="!isObfuscated"
:title="userSubAdminGroupsLabels?.length > 40 ? userSubAdminGroupsLabels : null">
{{ userSubAdminGroupsLabels }}
</span>
</td>
<td class="row__cell" data-cy-user-list-cell-quota>
<template v-if="editing">
<label
class="hidden-visually"
:for="'quota' + uniqueId">
{{ t('settings', 'Select account quota') }}
</label>
<NcSelect
v-model="editedUserQuota"
:create-option="validateQuota"
data-cy-user-list-input-quota
:data-loading="loading.quota || undefined"
:disabled="isLoadingField"
:loading="loading.quota"
:append-to-body="false"
:clearable="false"
:input-id="'quota' + uniqueId"
:options="quotaOptions"
:placeholder="t('settings', 'Select account quota')"
:taggable="true"
@option:selected="setUserQuota" />
</template>
<template v-else-if="!isObfuscated">
<template v-if="!isObfuscated">
<span :id="'quota-progress' + uniqueId">{{ userQuota }} ({{ usedSpace }})</span>
<NcProgressBar
:aria-labelledby="'quota-progress' + uniqueId"
@ -183,28 +75,7 @@
v-if="showConfig.showLanguages"
class="row__cell row__cell--large"
data-cy-user-list-cell-language>
<template v-if="editing">
<label
class="hidden-visually"
:for="'language' + uniqueId">
{{ t('settings', 'Set the language') }}
</label>
<NcSelect
:id="'language' + uniqueId"
data-cy-user-list-input-language
:data-loading="loading.languages || undefined"
:allow-empty="false"
:disabled="isLoadingField"
:loading="loading.languages"
:clearable="false"
:append-to-body="false"
:options="availableLanguages"
:placeholder="t('settings', 'No language set')"
:model-value="userLanguage"
label="name"
@input="setUserLanguage" />
</template>
<span v-else-if="!isObfuscated">
<span v-if="!isObfuscated">
{{ userLanguage.name }}
</span>
</td>
@ -240,31 +111,7 @@
</td>
<td class="row__cell row__cell--large row__cell--fill" data-cy-user-list-cell-manager>
<template v-if="editing">
<label
class="hidden-visually"
:for="'manager' + uniqueId">
{{ managerLabel }}
</label>
<NcSelect
v-model="currentManager"
class="select--fill"
data-cy-user-list-input-manager
:data-loading="loading.manager || undefined"
:input-id="'manager' + uniqueId"
:disabled="isLoadingField"
:loading="loadingPossibleManagers || loading.manager"
:options="possibleManagers"
:placeholder="managerLabel"
label="displayname"
:filterable="false"
:internal-search="false"
:clearable="true"
@open="searchInitialUserManager"
@search="searchUserManager"
@update:model-value="updateUserManager" />
</template>
<span v-else-if="!isObfuscated">
<span v-if="!isObfuscated">
{{ user.manager }}
</span>
</td>
@ -274,7 +121,6 @@
v-if="visible && !isObfuscated && canEdit && !loading.all"
:actions="userActions"
:disabled="isLoadingField"
:edit="editing"
:user="user"
@update:edit="toggleEdit" />
</td>
@ -283,19 +129,15 @@
<script>
import { getCurrentUser } from '@nextcloud/auth'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { showSuccess } from '@nextcloud/dialogs'
import { formatFileSize, parseFileSize } from '@nextcloud/files'
import { confirmPassword } from '@nextcloud/password-confirmation'
import { useFormatDateTime } from '@nextcloud/vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import UserRowActions from './UserRowActions.vue'
import logger from '../../logger.ts'
import UserRowMixin from '../../mixins/UserRowMixin.js'
import { loadUserGroups, loadUserSubAdminGroups, searchGroups } from '../../service/groups.ts'
import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts'
import { isObfuscated } from '../../utils/userUtils.ts'
const productName = window.OC.theme.productName
@ -306,15 +148,9 @@ export default {
NcAvatar,
NcLoadingIcon,
NcProgressBar,
NcSelect,
NcTextField,
UserRowActions,
},
mixins: [
UserRowMixin,
],
props: {
user: {
type: Object,
@ -350,49 +186,94 @@ export default {
type: Array,
default: () => [],
},
/** Callback from UserList to open the edit dialog */
onEditUser: {
type: Function,
default: null,
},
},
setup(props) {
const { formattedFullTime } = useFormatDateTime(props.user.firstLoginTimestamp * 1000, {
relativeTime: false,
format: {
timeStyle: 'short',
dateStyle: 'short',
},
})
return {
formattedFullTime,
}
},
data() {
return {
selectedQuota: false,
rand: Math.random().toString(36).substring(2),
loadingPossibleManagers: false,
possibleManagers: [],
currentManager: '',
editing: false,
loading: {
all: false,
displayName: false,
mailAddress: false,
groups: false,
groupsDetails: false,
subAdminGroupsDetails: false,
subadmins: false,
quota: false,
delete: false,
disable: false,
languages: false,
wipe: false,
manager: false,
},
editedDisplayName: this.user.displayname,
editedMail: this.user.email ?? '',
// Cancelable promise for search groups request
promise: null,
}
},
computed: {
managerLabel() {
// TRANSLATORS This string describes a person's manager in the context of an organization
return t('settings', 'Set line manager')
},
isObfuscated() {
return isObfuscated(this.user)
},
usedQuota() {
let quota = this.user.quota.quota
if (quota > 0) {
quota = Math.min(100, Math.round(this.user.quota.used / quota * 100))
} else {
const usedInGB = this.user.quota.used / (10 * Math.pow(2, 30))
// asymptotic curve approaching 50% at 10GB to visualize used space with infinite quota
quota = 95 * (1 - (1 / (usedInGB + 1)))
}
return isNaN(quota) ? 0 : quota
},
userLanguage() {
const availableLanguages = this.languages[0].languages.concat(this.languages[1].languages)
const userLang = availableLanguages.find((lang) => lang.code === this.user.language)
if (typeof userLang !== 'object' && this.user.language !== '') {
return {
code: this.user.language,
name: this.user.language,
}
} else if (this.user.language === '') {
return false
}
return userLang
},
userFirstLogin() {
if (this.user.firstLoginTimestamp > 0) {
return this.formattedFullTime
}
if (this.user.firstLoginTimestamp < 0) {
return t('settings', 'Unknown')
}
return t('settings', 'Never')
},
userLastLoginTooltip() {
if (this.user.lastLoginTimestamp > 0) {
return OC.Util.formatDate(this.user.lastLoginTimestamp * 1000)
}
return ''
},
userLastLogin() {
if (this.user.lastLoginTimestamp > 0) {
return OC.Util.relativeModifiedDate(this.user.lastLoginTimestamp * 1000)
}
return t('settings', 'Never')
},
showConfig() {
return this.$store.getters.getShowConfig
},
@ -409,34 +290,22 @@ export default {
return encodeURIComponent(this.user.id + this.rand)
},
availableGroups() {
const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin)
? this.$store.getters.getSortedGroups
: this.$store.getters.getSubAdminGroups
return groups.filter((group) => group.id !== '__nc_internal_recent' && group.id !== 'disabled')
},
availableSubAdminGroups() {
return this.availableGroups.filter((group) => group.id !== 'admin')
},
userGroupsLabels() {
return this.userGroups
.map((group) => {
// Try to match with more extensive group data
const availableGroup = this.availableGroups.find((g) => g.id === group.id)
return availableGroup?.name ?? group.name ?? group.id
const allGroups = this.$store.getters.getGroups
return this.user.groups
.map((id) => {
const group = allGroups.find((g) => g.id === id)
return group?.name ?? id
})
.join(', ')
},
userSubAdminGroupsLabels() {
return this.userSubAdminGroups
.map((group) => {
// Try to match with more extensive group data
const availableGroup = this.availableSubAdminGroups.find((g) => g.id === group.id)
return availableGroup?.name ?? group.name ?? group.id
const allGroups = this.$store.getters.getGroups
return (this.user.subadmin ?? [])
.map((id) => {
const group = allGroups.find((g) => g.id === id)
return group?.name ?? id
})
.join(', ')
},
@ -458,12 +327,10 @@ export default {
if (quota === 'default') {
quota = this.settings.defaultQuota
if (quota !== 'none') {
// convert to numeric value to match what the server would usually return
quota = parseFileSize(quota, true)
}
}
// when the default quota is unlimited, the server returns -3 here, map it to "none"
if (quota === 'none' || quota === -3) {
return t('settings', 'Unlimited')
} else if (quota >= 0) {
@ -499,37 +366,15 @@ export default {
}
return actions.concat(this.externalActions)
},
// mapping saved values to objects
editedUserQuota: {
get() {
if (this.selectedQuota !== false) {
return this.selectedQuota
}
if (this.settings.defaultQuota !== unlimitedQuota.id && parseFileSize(this.settings.defaultQuota, true) >= 0) {
// if value is valid, let's map the quotaOptions or return custom quota
return { id: this.settings.defaultQuota, label: this.settings.defaultQuota }
}
return unlimitedQuota // unlimited
},
set(quota) {
this.selectedQuota = quota
},
},
availableLanguages() {
return this.languages[0].languages.concat(this.languages[1].languages)
},
},
async beforeMount() {
if (this.user.manager) {
await this.initManager(this.user.manager)
}
},
methods: {
toggleEdit() {
if (this.onEditUser) {
this.onEditUser(this.user)
}
},
async wipeUserDevices() {
const userid = this.user.id
await confirmPassword()
@ -562,113 +407,6 @@ export default {
)
},
filterManagers(managers) {
return managers.filter((manager) => manager.id !== this.user.id)
},
async initManager(userId) {
await this.$store.dispatch('getUser', userId).then((response) => {
this.currentManager = response?.data.ocs.data
})
},
async searchInitialUserManager() {
this.loadingPossibleManagers = true
await this.searchUserManager()
this.loadingPossibleManagers = false
},
async loadGroupsDetails() {
this.loading.groups = true
this.loading.groupsDetails = true
try {
const groups = await loadUserGroups({ userId: this.user.id })
// Populate store from server request
for (const group of groups) {
this.$store.commit('addGroup', group)
}
} catch (error) {
logger.error(t('settings', 'Failed to load groups with details'), { error })
}
this.loading.groups = false
this.loading.groupsDetails = false
},
async loadSubAdminGroupsDetails() {
this.loading.subadmins = true
this.loading.subAdminGroupsDetails = true
try {
const groups = await loadUserSubAdminGroups({ userId: this.user.id })
// Populate store from server request
for (const group of groups) {
this.$store.commit('addGroup', group)
}
} catch (error) {
logger.error(t('settings', 'Failed to load sub admin groups with details'), { error })
}
this.loading.subadmins = false
this.loading.subAdminGroupsDetails = false
},
async searchGroups(query, toggleLoading) {
if (query === '') {
return // Prevent unexpected search behaviour e.g. on option:created
}
if (this.promise) {
this.promise.cancel()
}
toggleLoading(true)
try {
this.promise = await searchGroups({
search: query,
offset: 0,
limit: 25,
})
const groups = await this.promise
// Populate store from server request
for (const group of groups) {
this.$store.commit('addGroup', group)
}
} catch (error) {
logger.error(t('settings', 'Failed to search groups'), { error })
}
this.promise = null
toggleLoading(false)
},
async searchUserManager(query) {
await this.$store.dispatch('searchUsers', { offset: 0, limit: 10, search: query }).then((response) => {
const users = response?.data ? this.filterManagers(Object.values(response?.data.ocs.data.users)) : []
if (users.length > 0) {
this.possibleManagers = users
}
})
},
async updateUserManager() {
this.loading.manager = true
// Store the current manager before making changes
const previousManager = this.user.manager
try {
await this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'manager',
value: this.currentManager ? this.currentManager.id : '',
})
} catch (error) {
// TRANSLATORS This string describes a line manager in the context of an organization
showError(t('settings', 'Failed to update line manager'))
logger.error('Failed to update manager:', { error })
// Revert to the previous manager in the UI on error
this.currentManager = previousManager
} finally {
this.loading.manager = false
}
},
async deleteUser() {
const userid = this.user.id
await confirmPassword()
@ -711,242 +449,6 @@ export default {
})
},
/**
* Set user displayName
*/
async updateDisplayName() {
this.loading.displayName = true
try {
await this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'displayname',
value: this.editedDisplayName,
})
if (this.editedDisplayName === this.user.displayname) {
showSuccess(t('settings', 'Display name was successfully changed'))
}
} finally {
this.loading.displayName = false
}
},
/**
* Set user mailAddress
*/
async updateEmail() {
this.loading.mailAddress = true
if (this.editedMail === '') {
showError(t('settings', "Email can't be empty"))
this.loading.mailAddress = false
this.editedMail = this.user.email
} else {
try {
await this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'email',
value: this.editedMail,
})
if (this.editedMail === this.user.email) {
showSuccess(t('settings', 'Email was successfully changed'))
}
} finally {
this.loading.mailAddress = false
}
}
},
/**
* Create a new group and add user to it
*
* @param {string} gid Group id
*/
async createGroup({ name: gid }) {
this.loading.groups = true
try {
await this.$store.dispatch('addGroup', gid)
const userid = this.user.id
await this.$store.dispatch('addUserGroup', { userid, gid })
} catch (error) {
logger.error(t('settings', 'Failed to create group'), { error })
}
this.loading.groups = false
},
/**
* Add user to group
*
* @param {object} group Group object
*/
async addUserGroup(group) {
if (group.isCreating) {
// This is NcSelect's internal value for a new inputted group name
// Ignore
return
}
const userid = this.user.id
const gid = group.id
if (group.canAdd === false) {
return
}
this.loading.groups = true
try {
await this.$store.dispatch('addUserGroup', { userid, gid })
} catch (error) {
logger.error(error)
}
this.loading.groups = false
},
/**
* Remove user from group
*
* @param {object} group Group object
*/
async removeUserGroup(group) {
if (group.canRemove === false) {
return false
}
this.loading.groups = true
const userid = this.user.id
const gid = group.id
try {
await this.$store.dispatch('removeUserGroup', {
userid,
gid,
})
this.loading.groups = false
// remove user from current list if current list is the removed group
if (this.$route.params.selectedGroup === gid) {
this.$store.commit('deleteUser', userid)
}
} catch {
this.loading.groups = false
}
},
/**
* Add user to group
*
* @param {object} group Group object
*/
async addUserSubAdmin(group) {
this.loading.subadmins = true
const userid = this.user.id
const gid = group.id
try {
await this.$store.dispatch('addUserSubAdmin', {
userid,
gid,
})
} catch (error) {
logger.error(error)
}
this.loading.subadmins = false
},
/**
* Remove user from group
*
* @param {object} group Group object
*/
async removeUserSubAdmin(group) {
this.loading.subadmins = true
const userid = this.user.id
const gid = group.id
try {
await this.$store.dispatch('removeUserSubAdmin', {
userid,
gid,
})
} catch (error) {
logger.error(error)
} finally {
this.loading.subadmins = false
}
},
/**
* Dispatch quota set request
*
* @param {string | object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'}
* @return {string}
*/
async setUserQuota(quota = 'none') {
// Make sure correct label is set for unlimited quota
if (quota === 'none') {
quota = unlimitedQuota
}
this.loading.quota = true
// ensure we only send the preset id
quota = quota.id ? quota.id : quota
try {
// If human readable format, convert to raw float format
// Else just send the raw string
const value = (parseFileSize(quota, true) || quota).toString()
await this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'quota',
value,
})
} catch (error) {
logger.error(error)
} finally {
this.loading.quota = false
}
return quota
},
/**
* Validate quota string to make sure it's a valid human file size
*
* @param {string | object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'}
* @return {object} The validated quota object or unlimited quota if input is invalid
*/
validateQuota(quota) {
if (typeof quota === 'object') {
quota = quota?.id || quota.label
}
// only used for new presets sent through @Tag
const validQuota = parseFileSize(quota, true)
if (validQuota === null) {
return unlimitedQuota
} else {
// unify format output
quota = formatFileSize(parseFileSize(quota, true))
return { id: quota, label: quota }
}
},
/**
* Dispatch language set request
*
* @param {object} lang language object {code:'en', name:'English'}
* @return {object}
*/
async setUserLanguage(lang) {
this.loading.languages = true
// ensure we only send the preset id
try {
await this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'language',
value: lang.code,
})
this.loading.languages = false
} catch (error) {
logger.error(error)
}
return lang
},
/**
* Dispatch new welcome mail request
*/
sendWelcomeMail() {
this.loading.all = true
this.$store.dispatch('sendWelcomeMail', this.user.id)
@ -955,21 +457,6 @@ export default {
this.loading.all = false
})
},
async toggleEdit() {
this.editing = !this.editing
if (this.editing) {
await this.$nextTick()
this.$refs.displayNameField?.$refs?.inputField?.$refs?.input?.focus()
this.loadGroupsDetails()
this.loadSubAdminGroupsDetails()
}
if (this.editedDisplayName !== this.user.displayname) {
this.editedDisplayName = this.user.displayname
} else if (this.editedMail !== this.user.email) {
this.editedMail = this.user.email ?? ''
}
},
},
}
</script>
@ -987,11 +474,6 @@ export default {
background-color: var(--color-background-hover);
}
}
// Limit width of select in fill cell
.select--fill {
max-width: calc(var(--cell-width-large) - (2 * var(--cell-padding)));
}
}
.row {
@ -999,12 +481,6 @@ export default {
&__cell {
border-bottom: 1px solid var(--color-border);
:deep {
.v-select.select {
min-width: var(--cell-min-width);
}
}
}
&__progress {

View file

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

View file

@ -0,0 +1,343 @@
/**
* 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('detects displayName cleared to empty string', () => {
const initial = makeFormData({ displayName: 'Bob' })
const current = makeFormData({ displayName: '' })
expect(diffPayload(initial, current)).toEqual({ displayName: '' })
})
it('detects email cleared to empty string', () => {
const initial = makeFormData({ email: 'bob@example.com' })
const current = makeFormData({ email: '' })
expect(diffPayload(initial, current)).toEqual({ email: '' })
})
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

@ -1,139 +0,0 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { formatFileSize } from '@nextcloud/files'
import { useFormatDateTime } from '@nextcloud/vue'
export default {
props: {
user: {
type: Object,
required: true,
},
settings: {
type: Object,
default: () => ({}),
},
quotaOptions: {
type: Array,
default: () => [],
},
languages: {
type: Array,
required: true,
},
externalActions: {
type: Array,
default: () => [],
},
},
setup(props) {
const { formattedFullTime } = useFormatDateTime(props.user.firstLoginTimestamp * 1000, {
relativeTime: false,
format: {
timeStyle: 'short',
dateStyle: 'short',
},
})
return {
formattedFullTime,
}
},
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
/* QUOTA MANAGEMENT */
usedSpace() {
const quotaUsed = this.user.quota.used > 0 ? this.user.quota.used : 0
return t('settings', '{size} used', { size: formatFileSize(quotaUsed, true) })
},
usedQuota() {
let quota = this.user.quota.quota
if (quota > 0) {
quota = Math.min(100, Math.round(this.user.quota.used / quota * 100))
} else {
const usedInGB = this.user.quota.used / (10 * Math.pow(2, 30))
// asymptotic curve approaching 50% at 10GB to visualize used stace with infinite quota
quota = 95 * (1 - (1 / (usedInGB + 1)))
}
return isNaN(quota) ? 0 : quota
},
// Mapping saved values to objects
userQuota() {
if (this.user.quota.quota >= 0) {
// if value is valid, let's map the quotaOptions or return custom quota
const humanQuota = formatFileSize(this.user.quota.quota)
const userQuota = this.quotaOptions.find((quota) => quota.id === humanQuota)
return userQuota || { id: humanQuota, label: humanQuota }
} else if (this.user.quota.quota === 'default') {
// default quota is replaced by the proper value on load
return this.quotaOptions[0]
}
return this.quotaOptions[1] // unlimited
},
/* PASSWORD POLICY? */
minPasswordLength() {
return this.$store.getters.getPasswordPolicyMinLength
},
/* LANGUAGE */
userLanguage() {
const availableLanguages = this.languages[0].languages.concat(this.languages[1].languages)
const userLang = availableLanguages.find((lang) => lang.code === this.user.language)
if (typeof userLang !== 'object' && this.user.language !== '') {
return {
code: this.user.language,
name: this.user.language,
}
} else if (this.user.language === '') {
return false
}
return userLang
},
userFirstLogin() {
if (this.user.firstLoginTimestamp > 0) {
return this.formattedFullTime
}
if (this.user.firstLoginTimestamp < 0) {
return t('settings', 'Unknown')
}
return t('settings', 'Never')
},
/* LAST LOGIN */
userLastLoginTooltip() {
if (this.user.lastLoginTimestamp > 0) {
return OC.Util.formatDate(this.user.lastLoginTimestamp * 1000)
}
return ''
},
userLastLogin() {
if (this.user.lastLoginTimestamp > 0) {
return OC.Util.relativeModifiedDate(this.user.lastLoginTimestamp * 1000)
}
return t('settings', 'Never')
},
userGroups() {
const allGroups = this.$store.getters.getGroups
return this.user.groups
.map((id) => allGroups.find((g) => g.id === id))
.filter((group) => group !== undefined)
},
userSubAdminGroups() {
const allGroups = this.$store.getters.getGroups
return this.user.subadmin
.map((id) => allGroups.find((g) => g.id === id))
.filter((group) => group !== undefined)
},
},
}

View file

@ -215,6 +215,34 @@ const mutations = {
}
},
/**
* Apply multiple updated fields to a user in the local store.
*
* @param {object} state Store state
* @param {object} options destructuring object
* @param {string} options.userid User id
* @param {object} options.data Updated user data from server
*/
editUserMultiField(state, { userid, data }) {
const index = state.users.findIndex((user) => user.id === userid)
if (index === -1) {
return
}
// Delegate group membership changes so sidebar usercount stays in sync.
if (Array.isArray(data.groups)) {
const prevGids = state.users[index].groups ?? []
for (const gid of data.groups.filter((g) => !prevGids.includes(g))) {
this.commit('addUserGroup', { userid, gid })
}
for (const gid of prevGids.filter((g) => !data.groups.includes(g))) {
this.commit('removeUserGroup', { userid, gid })
}
}
state.users.splice(index, 1, { ...state.users[index], ...data })
},
/**
* Reset users list
*
@ -783,6 +811,29 @@ const actions = {
}
},
/**
* Update multiple user fields atomically via the new bulk endpoint.
*
* @param {object} context store context
* @param {object} options destructuring object
* @param {string} options.userid User id
* @param {object} options.payload Changed fields to send
* @return {Promise}
*/
async editUserMultiField(context, { userid, payload }) {
try {
await api.requireAdmin()
const response = await api.patch(
generateOcsUrl('cloud/users/{userid}', { userid }),
payload,
)
context.commit('editUserMultiField', { userid, data: response.data.ocs.data })
} catch (error) {
context.commit('API_FAILURE', { userid, error })
throw error
}
},
/**
* Send welcome mail
*

View file

@ -30,11 +30,8 @@
}
},
"apps/settings/src/components/Users/NewUserDialog.vue": {
"@nextcloud/no-deprecated-library-props": {
"count": 1
},
"vue/no-mutating-props": {
"count": 17
"count": 2
}
},
"apps/settings/src/views/UserManagementNavigation.vue": {

View file

@ -2269,16 +2269,16 @@
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getAppValue]]></code>
<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,61 @@ 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.
// User::getManagerUids() decodes this with JSON_THROW_ON_ERROR, so we
// must store a JSON array, matching what setManagerUids() writes.
// Double-quotes are escaped because runOccCommand passes the command
// through `bash -c "..."`, which would otherwise eat them.
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')

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
3145-3145.js.license

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,6 @@ SPDX-License-Identifier: MIT
SPDX-License-Identifier: ISC
SPDX-License-Identifier: GPL-3.0-or-later
SPDX-License-Identifier: BSD-3-Clause
SPDX-License-Identifier: Apache-2.0
SPDX-License-Identifier: AGPL-3.0-or-later
SPDX-License-Identifier: (MPL-2.0 OR Apache-2.0)
SPDX-FileCopyrightText: escape-html developers
@ -20,15 +19,11 @@ SPDX-FileCopyrightText: Eduardo San Martin Morote
SPDX-FileCopyrightText: Dr.-Ing. Mario Heiderich, Cure53 <mario@cure53.de> (https://cure53.de/)
SPDX-FileCopyrightText: David Clark
SPDX-FileCopyrightText: Christoph Wurst
SPDX-FileCopyrightText: Austin Andrews
SPDX-FileCopyrightText: Anthony Fu <https://github.com/antfu>
SPDX-FileCopyrightText: Anthony Fu <anthonyfu117@hotmail.com>
This file is generated from multiple sources. Included packages:
- @mdi/svg
- version: 7.4.47
- license: Apache-2.0
- @nextcloud/auth
- version: 2.5.3
- license: GPL-3.0-or-later

1
dist/4254-4254.js.map vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/4254-4254.js.map.license vendored Symbolic link
View file

@ -0,0 +1 @@
4254-4254.js.license

4
dist/core-common.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,6 @@ SPDX-License-Identifier: MIT
SPDX-License-Identifier: ISC
SPDX-License-Identifier: GPL-3.0-or-later
SPDX-License-Identifier: BSD-3-Clause
SPDX-License-Identifier: Apache-2.0
SPDX-License-Identifier: AGPL-3.0-or-later
SPDX-License-Identifier: (MPL-2.0 OR Apache-2.0)
SPDX-FileCopyrightText: webfansplz
@ -40,7 +39,6 @@ SPDX-FileCopyrightText: Eduardo San Martin Morote
SPDX-FileCopyrightText: Dr.-Ing. Mario Heiderich, Cure53 <mario@cure53.de> (https://cure53.de/)
SPDX-FileCopyrightText: David Clark
SPDX-FileCopyrightText: Christoph Wurst
SPDX-FileCopyrightText: Austin Andrews
SPDX-FileCopyrightText: Anthony Fu <https://github.com/antfu>
SPDX-FileCopyrightText: Anthony Fu <anthonyfu117@hotmail.com>
SPDX-FileCopyrightText: @nextcloud/dialogs developers
@ -53,9 +51,6 @@ This file is generated from multiple sources. Included packages:
- @floating-ui/utils
- version: 0.2.11
- license: MIT
- @mdi/svg
- version: 7.4.47
- license: Apache-2.0
- @nextcloud/auth
- version: 2.5.3
- license: GPL-3.0-or-later

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -96,9 +96,6 @@ This file is generated from multiple sources. Included packages:
- @mdi/js
- version: 7.4.47
- license: Apache-2.0
- @mdi/svg
- version: 7.4.47
- license: Apache-2.0
- @nextcloud/auth
- version: 2.5.3
- license: GPL-3.0-or-later

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -32020,6 +32020,210 @@
}
}
},
"patch": {
"operationId": "provisioning_api-users-edit-user-multi-field",
"summary": "Update multiple user account fields atomically. All submitted fields are validated first; if any fail, no changes are applied.",
"description": "Unlike editUser (which updates one field at a time via key/value), this method accepts named fields and applies them all in a single request.\nThis endpoint requires password confirmation",
"tags": [
"provisioning_api/users"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"displayName": {
"type": "string",
"nullable": true,
"default": null,
"description": "New display name (null = no change)"
},
"password": {
"type": "string",
"nullable": true,
"default": null,
"description": "New password (null = no change)"
},
"email": {
"type": "string",
"nullable": true,
"default": null,
"description": "New primary email (null = no change, '' = clear)"
},
"quota": {
"type": "string",
"nullable": true,
"default": null,
"description": "New quota e.g. \"5 GB\" (null = no change)"
},
"language": {
"type": "string",
"nullable": true,
"default": null,
"description": "Language code e.g. \"de\" (null = no change)"
},
"manager": {
"type": "string",
"nullable": true,
"default": null,
"description": "Manager user ID (null = no change, '' = clear)"
},
"groups": {
"type": "array",
"nullable": true,
"default": null,
"description": "Group IDs to assign (null = no change, [] = remove all)",
"items": {
"type": "string"
}
},
"subadminGroups": {
"type": "array",
"nullable": true,
"default": null,
"description": "Subadmin group IDs (null = no change, [] = remove all)",
"items": {
"type": "string"
}
}
}
}
}
}
},
"parameters": [
{
"name": "userId",
"in": "path",
"description": "The user to update",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "User updated successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"$ref": "#/components/schemas/ProvisioningApiUserDetails"
}
}
}
}
}
}
}
},
"422": {
"description": "One or more submitted fields failed validation",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"errors"
],
"properties": {
"errors": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"delete": {
"operationId": "provisioning_api-users-delete-user",
"summary": "Delete a user",