mirror of
https://github.com/nextcloud/server.git
synced 2026-05-22 01:55:56 -04:00
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:
commit
4d0cba89ab
44 changed files with 2938 additions and 1491 deletions
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
||||
|
|
|
|||
155
apps/settings/src/components/Users/EditUserDialog.vue
Normal file
155
apps/settings/src/components/Users/EditUserDialog.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
249
apps/settings/src/components/Users/UserFormFields.vue
Normal file
249
apps/settings/src/components/Users/UserFormFields.vue
Normal 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>
|
||||
141
apps/settings/src/components/Users/UserFormGroups.vue
Normal file
141
apps/settings/src/components/Users/UserFormGroups.vue
Normal 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>
|
||||
56
apps/settings/src/components/Users/UserFormLanguage.vue
Normal file
56
apps/settings/src/components/Users/UserFormLanguage.vue
Normal 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>
|
||||
109
apps/settings/src/components/Users/UserFormManager.vue
Normal file
109
apps/settings/src/components/Users/UserFormManager.vue
Normal 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>
|
||||
46
apps/settings/src/components/Users/UserFormQuota.vue
Normal file
46
apps/settings/src/components/Users/UserFormQuota.vue
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
343
apps/settings/src/components/Users/userFormUtils.spec.ts
Normal file
343
apps/settings/src/components/Users/userFormUtils.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
179
apps/settings/src/components/Users/userFormUtils.ts
Normal file
179
apps/settings/src/components/Users/userFormUtils.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IGroup } from '../../views/user-types.d.ts'
|
||||
|
||||
import { formatFileSize, parseFileSize } from '@nextcloud/files'
|
||||
import { unlimitedQuota } from '../../utils/userUtils.ts'
|
||||
|
||||
interface QuotaOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface LanguageOption {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
username: string
|
||||
displayName: string
|
||||
password: string
|
||||
email: string
|
||||
groups: IGroup[]
|
||||
subadminGroups: IGroup[]
|
||||
quota: QuotaOption
|
||||
language: LanguageOption
|
||||
manager: string | { id: string, displayname?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the user's language code to a { code, name } object.
|
||||
*
|
||||
* @param user The user store object
|
||||
* @param serverLanguages Server language configuration
|
||||
* @return Language object with code and name
|
||||
*/
|
||||
export function resolveLanguage(user, serverLanguages): LanguageOption {
|
||||
if (!user.language || user.language === '') {
|
||||
return { code: '', name: '' }
|
||||
}
|
||||
const allLangs = [
|
||||
...(serverLanguages?.commonLanguages ?? []),
|
||||
...(serverLanguages?.otherLanguages ?? []),
|
||||
]
|
||||
const match = allLangs.find((lang) => lang.code === user.language)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
return { code: user.language, name: user.language }
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a user store object to the flat, API-aligned shape used by the form.
|
||||
* Keeps a clean separation between the store model (e.g. `user.displayname`,
|
||||
* `user.quota.quota`) and the form model (e.g. `displayName`, `quota`).
|
||||
*
|
||||
* @param user The user store object
|
||||
* @param allGroups All available groups from the store
|
||||
* @param quotaOptions Quota preset options
|
||||
* @param serverLanguages Server language configuration
|
||||
* @return Form-ready data object
|
||||
*/
|
||||
export function userToFormData(user, allGroups, quotaOptions, serverLanguages) {
|
||||
const groups = user.groups
|
||||
.map((id) => allGroups.find((g) => g.id === id))
|
||||
.filter(Boolean)
|
||||
|
||||
const subadminGroups = (user.subadmin ?? [])
|
||||
.map((id) => allGroups.find((g) => g.id === id))
|
||||
.filter(Boolean)
|
||||
|
||||
let quota
|
||||
if (user.quota?.quota >= 0) {
|
||||
const label = formatFileSize(user.quota.quota)
|
||||
quota = quotaOptions.find((q) => q.id === label) ?? { id: label, label }
|
||||
} else if (user.quota?.quota === 'default') {
|
||||
quota = quotaOptions[0]
|
||||
} else {
|
||||
quota = unlimitedQuota
|
||||
}
|
||||
|
||||
return {
|
||||
username: user.id,
|
||||
displayName: user.displayname ?? '',
|
||||
password: '',
|
||||
email: user.email ?? '',
|
||||
groups,
|
||||
subadminGroups,
|
||||
quota,
|
||||
language: resolveLanguage(user, serverLanguages),
|
||||
manager: user.manager ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic shallow diff between initial and current form data.
|
||||
* Returns only fields that changed, with API-ready values.
|
||||
*
|
||||
* @param initial Snapshot of form data at mount time
|
||||
* @param current Current form data state
|
||||
* @return Changed fields with API-ready values
|
||||
*/
|
||||
export function diffPayload(initial: FormData, current: FormData) {
|
||||
const payload: Record<string, string | string[]> = {}
|
||||
|
||||
if (current.displayName !== initial.displayName) {
|
||||
payload.displayName = current.displayName
|
||||
}
|
||||
if (current.password !== '') {
|
||||
payload.password = current.password
|
||||
}
|
||||
if (current.email !== initial.email) {
|
||||
payload.email = current.email
|
||||
}
|
||||
if (current.quota.id !== initial.quota.id) {
|
||||
payload.quota = current.quota.id
|
||||
}
|
||||
if (current.language.code !== initial.language.code) {
|
||||
payload.language = current.language.code
|
||||
}
|
||||
const currentManagerId = typeof current.manager === 'object' ? (current.manager.id ?? '') : current.manager
|
||||
const initialManagerId = typeof initial.manager === 'object' ? (initial.manager.id ?? '') : initial.manager
|
||||
if (currentManagerId !== initialManagerId) {
|
||||
payload.manager = currentManagerId
|
||||
}
|
||||
|
||||
const currentGroupIds = current.groups.map((g) => g.id).sort()
|
||||
const initialGroupIds = initial.groups.map((g) => g.id).sort()
|
||||
if (JSON.stringify(currentGroupIds) !== JSON.stringify(initialGroupIds)) {
|
||||
payload.groups = currentGroupIds
|
||||
}
|
||||
|
||||
const currentSubadminIds = current.subadminGroups.map((g) => g.id).sort()
|
||||
const initialSubadminIds = initial.subadminGroups.map((g) => g.id).sort()
|
||||
if (JSON.stringify(currentSubadminIds) !== JSON.stringify(initialSubadminIds)) {
|
||||
payload.subadminGroups = currentSubadminIds
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and normalizes a user-entered quota string into a quota option.
|
||||
* Returns the fallback option if the input is invalid.
|
||||
*
|
||||
* @param quota Raw quota string entered by the user (e.g. "4 MB")
|
||||
* @param fallback Fallback option when input is invalid
|
||||
* @param fallback.id Fallback option identifier
|
||||
* @param fallback.label Fallback option display label
|
||||
* @return Normalized quota option with id and label
|
||||
*/
|
||||
export function validateQuota(quota: string, fallback: { id: string, label: string }) {
|
||||
const parsed = parseFileSize(quota, true)
|
||||
if (parsed !== null && parsed >= 0) {
|
||||
const label = formatFileSize(parsed)
|
||||
return { id: label, label }
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter function for the language NcSelect. Handles grouped options
|
||||
* (section headers with nested languages) and plain language entries.
|
||||
*
|
||||
* @param option The select option being filtered
|
||||
* @param option.languages Nested languages for group headers
|
||||
* @param label The option's display label
|
||||
* @param search The user's search string
|
||||
* @return Whether the option matches the search
|
||||
*/
|
||||
export function languageFilterBy(option: { languages?: Array<{ name: string }> }, label: string, search: string): boolean {
|
||||
if (option.languages) {
|
||||
return option.languages.some(({ name }) => name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||
}
|
||||
return (label || '').toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -51,24 +51,26 @@ export function waitLoading(selector: string) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Toggle the edit button of the user row
|
||||
* Open the edit dialog for a user by clicking the Edit action on their row
|
||||
*
|
||||
* @param user The user row to edit
|
||||
* @param toEdit True if it should be switch to edit mode, false to switch to read-only
|
||||
* @param user The user whose edit dialog to open
|
||||
*/
|
||||
export function toggleEditButton(user: User, toEdit = true) {
|
||||
// see that the list of users contains the user
|
||||
export function openEditDialog(user: User) {
|
||||
getUserListRow(user.userId).should('exist')
|
||||
// toggle the edit mode for the user
|
||||
.find('[data-cy-user-list-cell-actions]')
|
||||
.find(`[data-cy-user-list-action-toggle-edit="${!toEdit}"]`)
|
||||
.if()
|
||||
.find('[data-cy-user-list-action-edit]')
|
||||
.click({ force: true })
|
||||
.else()
|
||||
// otherwise ensure the button is already in edit mode
|
||||
.then(() => getUserListRow(user.userId)
|
||||
.find(`[data-cy-user-list-action-toggle-edit="${toEdit}"]`)
|
||||
.should('exist'))
|
||||
// Wait for the dialog to appear
|
||||
cy.get('.edit-dialog [data-test="form"]').should('be.visible')
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the currently open edit dialog by clicking the Save button
|
||||
* and wait for the dialog to close
|
||||
*/
|
||||
export function saveEditDialog() {
|
||||
cy.get('[data-test="submit"]').click()
|
||||
// Wait for dialog to close
|
||||
cy.get('.edit-dialog').should('not.exist')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
import { clearState } from '../../support/commonUtils.ts'
|
||||
import { randomString } from '../../support/utils/randomString.ts'
|
||||
import { assertNotExistOrNotVisible, getUserListRow, handlePasswordConfirmation, toggleEditButton } from './usersUtils.ts'
|
||||
import { assertNotExistOrNotVisible, getUserListRow, handlePasswordConfirmation, openEditDialog, saveEditDialog } from './usersUtils.ts'
|
||||
|
||||
const admin = new User('admin', 'admin')
|
||||
|
||||
|
|
@ -89,34 +89,27 @@ describe('Settings: Assign user to a group', { testIsolation: false }, () => {
|
|||
.scrollIntoView()
|
||||
})
|
||||
|
||||
it('switch into user edit mode', () => {
|
||||
toggleEditButton(testUser)
|
||||
getUserListRow(testUser.userId)
|
||||
.find('[data-cy-user-list-input-groups]')
|
||||
.should('exist')
|
||||
})
|
||||
it('assign the group via the edit dialog', () => {
|
||||
openEditDialog(testUser)
|
||||
|
||||
it('assign the group', () => {
|
||||
// focus inside the input
|
||||
getUserListRow(testUser.userId)
|
||||
.find('[data-cy-user-list-input-groups] input')
|
||||
.click({ force: true })
|
||||
// enter the group name
|
||||
getUserListRow(testUser.userId)
|
||||
.find('[data-cy-user-list-input-groups] input')
|
||||
.type(`${groupName.slice(0, 5)}`) // only type part as otherwise we would create a new one with the same name
|
||||
cy.contains('li.vs__dropdown-option', groupName)
|
||||
.click({ force: true })
|
||||
// Type part of the group name in the groups NcSelect
|
||||
cy.get('.edit-dialog [data-test="form"]').within(() => {
|
||||
cy.get('[data-test="groups"] input[type="search"]').click({ force: true })
|
||||
cy.get('[data-test="groups"] input[type="search"]').type(groupName.slice(0, 5))
|
||||
})
|
||||
|
||||
// Select the group from the floating dropdown
|
||||
cy.get('.vs__dropdown-menu').should('be.visible')
|
||||
.contains('li', groupName).click({ force: true })
|
||||
|
||||
handlePasswordConfirmation(admin.password)
|
||||
})
|
||||
saveEditDialog()
|
||||
|
||||
it('leave the user edit mode', () => {
|
||||
toggleEditButton(testUser, false)
|
||||
cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
|
||||
})
|
||||
|
||||
it('see the group was successfully assigned', () => {
|
||||
// see a new memeber
|
||||
// see a new member
|
||||
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
|
||||
.find('.counter-bubble__counter')
|
||||
.should('contain', '1')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { User } from '@nextcloud/e2e-test-server/cypress'
|
||||
import { clearState } from '../../support/commonUtils.ts'
|
||||
import { getUserListRow, handlePasswordConfirmation, toggleEditButton, waitLoading } from './usersUtils.ts'
|
||||
import { handlePasswordConfirmation, openEditDialog, saveEditDialog } from './usersUtils.ts'
|
||||
|
||||
const admin = new User('admin', 'admin')
|
||||
|
||||
|
|
@ -21,101 +21,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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
1
dist/3145-3145.js.map
vendored
1
dist/3145-3145.js.map
vendored
File diff suppressed because one or more lines are too long
1
dist/3145-3145.js.map.license
vendored
1
dist/3145-3145.js.map.license
vendored
|
|
@ -1 +0,0 @@
|
|||
3145-3145.js.license
|
||||
4
dist/3145-3145.js → dist/4254-4254.js
vendored
4
dist/3145-3145.js → dist/4254-4254.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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
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
1
dist/4254-4254.js.map.license
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
4254-4254.js.license
|
||||
4
dist/core-common.js
vendored
4
dist/core-common.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-common.js.map
vendored
2
dist/core-common.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-init.js
vendored
4
dist/files_sharing-init.js
vendored
File diff suppressed because one or more lines are too long
5
dist/files_sharing-init.js.license
vendored
5
dist/files_sharing-init.js.license
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
dist/files_sharing-init.js.map
vendored
2
dist/files_sharing-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/settings-users-3239.js
vendored
4
dist/settings-users-3239.js
vendored
File diff suppressed because one or more lines are too long
3
dist/settings-users-3239.js.license
vendored
3
dist/settings-users-3239.js.license
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
dist/settings-users-3239.js.map
vendored
2
dist/settings-users-3239.js.map
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
204
openapi.json
204
openapi.json
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue