diff --git a/apps/provisioning_api/appinfo/routes.php b/apps/provisioning_api/appinfo/routes.php index 78526ce640234..66d1fb699a9f6 100644 --- a/apps/provisioning_api/appinfo/routes.php +++ b/apps/provisioning_api/appinfo/routes.php @@ -42,9 +42,11 @@ ['root' => '/cloud', 'name' => 'Users#enableUser', 'url' => '/users/{userId}/enable', 'verb' => 'PUT'], ['root' => '/cloud', 'name' => 'Users#disableUser', 'url' => '/users/{userId}/disable', 'verb' => 'PUT'], ['root' => '/cloud', 'name' => 'Users#getUsersGroups', 'url' => '/users/{userId}/groups', 'verb' => 'GET'], + ['root' => '/cloud', 'name' => 'Users#getUsersGroupsDetails', 'url' => '/users/{userId}/groups/details', 'verb' => 'GET'], ['root' => '/cloud', 'name' => 'Users#addToGroup', 'url' => '/users/{userId}/groups', 'verb' => 'POST'], ['root' => '/cloud', 'name' => 'Users#removeFromGroup', 'url' => '/users/{userId}/groups', 'verb' => 'DELETE'], ['root' => '/cloud', 'name' => 'Users#getUserSubAdminGroups', 'url' => '/users/{userId}/subadmins', 'verb' => 'GET'], + ['root' => '/cloud', 'name' => 'Users#getUserSubAdminGroupsDetails', 'url' => '/users/{userId}/subadmins/details', 'verb' => 'GET'], ['root' => '/cloud', 'name' => 'Users#addSubAdmin', 'url' => '/users/{userId}/subadmins', 'verb' => 'POST'], ['root' => '/cloud', 'name' => 'Users#removeSubAdmin', 'url' => '/users/{userId}/subadmins', 'verb' => 'DELETE'], ['root' => '/cloud', 'name' => 'Users#resendWelcomeMessage', 'url' => '/users/{userId}/welcome', 'verb' => 'POST'], diff --git a/apps/provisioning_api/lib/Controller/UsersController.php b/apps/provisioning_api/lib/Controller/UsersController.php index de87827906fac..6b22a010a8ccc 100644 --- a/apps/provisioning_api/lib/Controller/UsersController.php +++ b/apps/provisioning_api/lib/Controller/UsersController.php @@ -12,6 +12,7 @@ use InvalidArgumentException; use OC\Authentication\Token\RemoteWipe; +use OC\Group\Group; use OC\KnownUser\KnownUserService; use OC\User\Backend; use OCA\Provisioning_API\ResponseDefinitions; @@ -52,6 +53,7 @@ use Psr\Log\LoggerInterface; /** + * @psalm-import-type Provisioning_APIGroupDetails from ResponseDefinitions * @psalm-import-type Provisioning_APIUserDetails from ResponseDefinitions */ class UsersController extends AUserDataOCSController { @@ -1402,6 +1404,127 @@ public function getUsersGroups(string $userId): DataResponse { } } + /** + * @NoSubAdminRequired + * + * Get a list of groups with details + * + * @param string $userId ID of the user + * @return DataResponse}, array{}> + * @throws OCSException + * + * 200: Users groups returned + */ + #[NoAdminRequired] + public function getUsersGroupsDetails(string $userId): DataResponse { + $loggedInUser = $this->userSession->getUser(); + + $targetUser = $this->userManager->get($userId); + if ($targetUser === null) { + throw new OCSException('', OCSController::RESPOND_NOT_FOUND); + } + + $isAdmin = $this->groupManager->isAdmin($loggedInUser->getUID()); + $isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($loggedInUser->getUID()); + if ($targetUser->getUID() === $loggedInUser->getUID() || $isAdmin || $isDelegatedAdmin) { + // Self lookup or admin lookup + $groups = array_map( + function (Group $group) { + return [ + 'id' => $group->getGID(), + 'displayname' => $group->getDisplayName(), + 'usercount' => $group->count(), + 'disabled' => $group->countDisabled(), + 'canAdd' => $group->canAddUser(), + 'canRemove' => $group->canRemoveUser(), + ]; + }, + array_values($this->groupManager->getUserGroups($targetUser)), + ); + return new DataResponse([ + 'groups' => $groups, + ]); + } else { + $subAdminManager = $this->groupManager->getSubAdmin(); + + // Looking up someone else + if ($subAdminManager->isUserAccessible($loggedInUser, $targetUser)) { + // Return the group that the method caller is subadmin of for the user in question + $gids = array_values(array_intersect( + array_map( + static fn (IGroup $group) => $group->getGID(), + $subAdminManager->getSubAdminsGroups($loggedInUser), + ), + $this->groupManager->getUserGroupIds($targetUser) + )); + $groups = array_map( + function (string $gid) { + $group = $this->groupManager->get($gid); + return [ + 'id' => $group->getGID(), + 'displayname' => $group->getDisplayName(), + 'usercount' => $group->count(), + 'disabled' => $group->countDisabled(), + 'canAdd' => $group->canAddUser(), + 'canRemove' => $group->canRemoveUser(), + ]; + }, + $gids, + ); + return new DataResponse([ + 'groups' => $groups, + ]); + } else { + // Not permitted + throw new OCSException('', OCSController::RESPOND_NOT_FOUND); + } + } + } + + /** + * @NoSubAdminRequired + * + * Get a list of the groups the user is a subadmin of, with details + * + * @param string $userId ID of the user + * @return DataResponse}, array{}> + * @throws OCSException + * + * 200: Users subadmin groups returned + */ + #[NoAdminRequired] + public function getUserSubAdminGroupsDetails(string $userId): DataResponse { + $loggedInUser = $this->userSession->getUser(); + + $targetUser = $this->userManager->get($userId); + if ($targetUser === null) { + throw new OCSException('', OCSController::RESPOND_NOT_FOUND); + } + + $isAdmin = $this->groupManager->isAdmin($loggedInUser->getUID()); + $isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($loggedInUser->getUID()); + if ($targetUser->getUID() === $loggedInUser->getUID() || $isAdmin || $isDelegatedAdmin) { + $subAdminManager = $this->groupManager->getSubAdmin(); + $groups = array_map( + function (IGroup $group) { + return [ + 'id' => $group->getGID(), + 'displayname' => $group->getDisplayName(), + 'usercount' => $group->count(), + 'disabled' => $group->countDisabled(), + 'canAdd' => $group->canAddUser(), + 'canRemove' => $group->canRemoveUser(), + ]; + }, + array_values($subAdminManager->getSubAdminsGroups($targetUser)), + ); + return new DataResponse([ + 'groups' => $groups, + ]); + } + throw new OCSException('', OCSController::RESPOND_NOT_FOUND); + } + /** * Add a user to a group * diff --git a/apps/provisioning_api/openapi-full.json b/apps/provisioning_api/openapi-full.json index c63e466e2ac49..9079404c35c93 100644 --- a/apps/provisioning_api/openapi-full.json +++ b/apps/provisioning_api/openapi-full.json @@ -4115,6 +4115,168 @@ } } }, + "/ocs/v2.php/cloud/users/{userId}/groups/details": { + "get": { + "operationId": "users-get-users-groups-details", + "summary": "Get a list of groups with details", + "tags": [ + "users" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "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": "Users groups returned", + "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": [ + "groups" + ], + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupDetails" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/cloud/users/{userId}/subadmins/details": { + "get": { + "operationId": "users-get-user-sub-admin-groups-details", + "summary": "Get a list of the groups the user is a subadmin of, with details", + "tags": [ + "users" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "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": "Users subadmin groups returned", + "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": [ + "groups" + ], + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupDetails" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/cloud/users/{userId}/welcome": { "post": { "operationId": "users-resend-welcome-message", diff --git a/apps/provisioning_api/openapi.json b/apps/provisioning_api/openapi.json index a55b8c5771ff7..59f31a2c25d23 100644 --- a/apps/provisioning_api/openapi.json +++ b/apps/provisioning_api/openapi.json @@ -2547,6 +2547,168 @@ } } }, + "/ocs/v2.php/cloud/users/{userId}/groups/details": { + "get": { + "operationId": "users-get-users-groups-details", + "summary": "Get a list of groups with details", + "tags": [ + "users" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "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": "Users groups returned", + "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": [ + "groups" + ], + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupDetails" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/cloud/users/{userId}/subadmins/details": { + "get": { + "operationId": "users-get-user-sub-admin-groups-details", + "summary": "Get a list of the groups the user is a subadmin of, with details", + "tags": [ + "users" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "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": "Users subadmin groups returned", + "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": [ + "groups" + ], + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupDetails" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/cloud/users/{userId}/welcome": { "post": { "operationId": "users-resend-welcome-message", diff --git a/apps/settings/lib/Controller/UsersController.php b/apps/settings/lib/Controller/UsersController.php index 739943d91b0c9..e97e497b9c12a 100644 --- a/apps/settings/lib/Controller/UsersController.php +++ b/apps/settings/lib/Controller/UsersController.php @@ -134,8 +134,15 @@ public function usersList(): TemplateResponse { $this->userSession ); - $groupsInfo->setSorting($sortGroupsBy); - [$adminGroup, $groups] = $groupsInfo->get(); + $adminGroup = $this->groupManager->get('admin'); + $adminGroupData = [ + 'id' => $adminGroup->getGID(), + 'name' => $adminGroup->getDisplayName(), + 'usercount' => $sortGroupsBy === MetaData::SORT_USERCOUNT ? $adminGroup->count() : 0, + 'disabled' => $adminGroup->countDisabled(), + 'canAdd' => $adminGroup->canAddUser(), + 'canRemove' => $adminGroup->canRemoveUser(), + ]; if (!$isLDAPUsed && $this->appManager->isEnabledForUser('user_ldap')) { $isLDAPUsed = (bool)array_reduce($this->userManager->getBackends(), function ($ldapFound, $backend) { @@ -196,7 +203,7 @@ public function usersList(): TemplateResponse { /* FINAL DATA */ $serverData = []; // groups - $serverData['groups'] = array_merge_recursive($adminGroup, [$recentUsersGroup, $disabledUsersGroup], $groups); + $serverData['systemGroups'] = [$adminGroupData, $recentUsersGroup, $disabledUsersGroup]; // Various data $serverData['isAdmin'] = $isAdmin; $serverData['isDelegatedAdmin'] = $isDelegatedAdmin; diff --git a/apps/settings/src/components/AppNavigationGroupList.vue b/apps/settings/src/components/AppNavigationGroupList.vue new file mode 100644 index 0000000000000..b32a07bc9b8b9 --- /dev/null +++ b/apps/settings/src/components/AppNavigationGroupList.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/apps/settings/src/components/GroupListItem.vue b/apps/settings/src/components/GroupListItem.vue index 19786507b4837..65d46136ec144 100644 --- a/apps/settings/src/components/GroupListItem.vue +++ b/apps/settings/src/components/GroupListItem.vue @@ -29,6 +29,7 @@ group.id !== '__nc_internal_recent' && group.id !== 'disabled') - .sort((a, b) => a.name.localeCompare(b.name)) - }, - - subAdminsGroups() { - // data provided php side - return this.$store.getters.getSubadminGroups }, quotaOptions() { diff --git a/apps/settings/src/components/Users/NewUserDialog.vue b/apps/settings/src/components/Users/NewUserDialog.vue index 39757602ee133..3e50efc2072b5 100644 --- a/apps/settings/src/components/Users/NewUserDialog.vue +++ b/apps/settings/src/components/Users/NewUserDialog.vue @@ -64,29 +64,32 @@ :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="canAddGroups" + :options="availableGroups" :value="newUser.groups" label="name" :close-on-select="false" :multiple="true" :taggable="true" :required="!settings.isAdmin && !settings.isDelegatedAdmin" - @input="handleGroupInput" - @option:created="createGroup" /> + :create-option="(value) => ({ id: value, name: value, isCreating: true })" + @search="searchGroups" + @option:created="createGroup" + @option:selected="options => addGroup(options.at(-1))" /> -
+
+ label="name" + @search="searchGroups" />
group.id !== '__nc_internal_recent' && group.id !== 'disabled'), 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, } }, @@ -200,27 +209,9 @@ export default { return this.$store.getters.getPasswordPolicyMinLength }, - groups() { - // data provided php side + remove the recent and disabled groups - return this.$store.getters.getGroups - .filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled') - .sort((a, b) => a.name.localeCompare(b.name)) - }, - subAdminsGroups() { // data provided php side - return this.$store.getters.getSubadminGroups - }, - - canAddGroups() { - // disabled if no permission to add new users to group - return this.groups.map(group => { - // clone object because we don't want - // to edit the original groups - group = Object.assign({}, group) - group.$isDisabled = group.canAdd === false - return group - }) + return this.availableGroups.filter(group => group.id !== 'admin' && group.id !== '__nc_internal_recent' && group.id !== 'disabled') }, languages() { @@ -281,13 +272,24 @@ export default { } }, - handleGroupInput(groups) { - /** - * Filter out groups with no id to prevent duplicate selected options - * - * Created groups are added programmatically by `createGroup()` - */ - this.newUser.groups = groups.filter(group => Boolean(group.id)) + async searchGroups(query, toggleLoading) { + if (this.promise) { + this.promise.cancel() + } + toggleLoading(true) + try { + this.promise = searchGroups({ + search: query, + offset: 0, + limit: 25, + }) + const groups = await this.promise + this.availableGroups = groups + } catch (error) { + logger.error(t('settings', 'Failed to search groups'), { error }) + } + this.promise = null + toggleLoading(false) }, /** @@ -300,11 +302,27 @@ export default { this.loading.groups = true try { await this.$store.dispatch('addGroup', gid) - this.newUser.groups.push(this.groups.find(group => group.id === gid)) - this.loading.groups = false + this.availableGroups.push({ id: gid, name: gid }) + this.newUser.groups.push({ id: gid, name: gid }) } catch (error) { - this.loading.groups = false + 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) }, /** diff --git a/apps/settings/src/components/Users/UserListHeader.vue b/apps/settings/src/components/Users/UserListHeader.vue index 6bc547228a4f8..a85306d84d3cf 100644 --- a/apps/settings/src/components/Users/UserListHeader.vue +++ b/apps/settings/src/components/Users/UserListHeader.vue @@ -42,7 +42,7 @@ scope="col"> {{ t('settings', 'Groups') }} - @@ -125,11 +125,6 @@ export default Vue.extend({ return this.$store.getters.getServerData }, - subAdminsGroups() { - // @ts-expect-error: allow untyped $store - return this.$store.getters.getSubadminGroups - }, - passwordLabel(): string { if (this.hasObfuscated) { // TRANSLATORS This string is for a column header labelling either a password or a message that the current user has insufficient permissions diff --git a/apps/settings/src/components/Users/UserRow.vue b/apps/settings/src/components/Users/UserRow.vue index 485a139e28583..1432ed2c6af60 100644 --- a/apps/settings/src/components/Users/UserRow.vue +++ b/apps/settings/src/components/Users/UserRow.vue @@ -106,7 +106,7 @@ :data-loading="loading.groups || undefined" :input-id="'groups' + uniqueId" :close-on-select="false" - :disabled="isLoadingField" + :disabled="isLoadingField || loading.groupsDetails" :loading="loading.groups" :multiple="true" :append-to-body="false" @@ -116,7 +116,8 @@ :value="userGroups" label="name" :no-wrap="true" - :create-option="(value) => ({ name: value, isCreating: 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" /> @@ -127,10 +128,10 @@ - -