Merge pull request #51336 from nextcloud/perf/paginate-filter-groups

fix(settings): Fix infinitely loading account management page with pagination of groups
pull/51784/head
Pytal 2025-03-28 14:54:21 +07:00 committed by GitHub
commit 4a9bd9bb6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1196 additions and 271 deletions

@ -42,9 +42,11 @@ return [
['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'],

@ -12,6 +12,7 @@ namespace OCA\Provisioning_API\Controller;
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 OCP\Util;
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 @@ class UsersController extends AUserDataOCSController {
}
}
/**
* @NoSubAdminRequired
*
* Get a list of groups with details
*
* @param string $userId ID of the user
* @return DataResponse<Http::STATUS_OK, array{groups: list<Provisioning_APIGroupDetails>}, 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<Http::STATUS_OK, array{groups: list<Provisioning_APIGroupDetails>}, 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
*

@ -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",

@ -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",

@ -134,8 +134,15 @@ class UsersController extends Controller {
$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 @@ class UsersController extends Controller {
/* 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;

@ -0,0 +1,200 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<Fragment>
<NcAppNavigationCaption :name="t('settings', 'Groups')"
:disabled="loadingAddGroup"
:aria-label="loadingAddGroup ? t('settings', 'Creating group…') : t('settings', 'Create group')"
force-menu
is-heading
:open.sync="isAddGroupOpen">
<template v-if="isAdminOrDelegatedAdmin" #actionsTriggerIcon>
<NcLoadingIcon v-if="loadingAddGroup" />
<NcIconSvgWrapper v-else :path="mdiPlus" />
</template>
<template v-if="isAdminOrDelegatedAdmin" #actions>
<NcActionText>
<template #icon>
<NcIconSvgWrapper :path="mdiAccountGroup" />
</template>
{{ t('settings', 'Create group') }}
</NcActionText>
<NcActionInput :label="t('settings', 'Group name')"
data-cy-users-settings-new-group-name
:label-outside="false"
:disabled="loadingAddGroup"
:value.sync="newGroupName"
:error="hasAddGroupError"
:helper-text="hasAddGroupError ? t('settings', 'Please enter a valid group name') : ''"
@submit="createGroup" />
</template>
</NcAppNavigationCaption>
<NcAppNavigationSearch v-model="groupsSearchQuery"
:label="t('settings', 'Search groups…')" />
<p id="group-list-desc" class="hidden-visually">
{{ t('settings', 'List of groups. This list is not fully populated for performance reasons. The groups will be loaded as you navigate or search through the list.') }}
</p>
<NcAppNavigationList class="account-management__group-list"
aria-describedby="group-list-desc"
data-cy-users-settings-navigation-groups="custom">
<GroupListItem v-for="group in userGroups"
:id="group.id"
ref="groupListItems"
:key="group.id"
:active="selectedGroupDecoded === group.id"
:name="group.title"
:count="group.count" />
<div v-if="loadingGroups" role="note">
<NcLoadingIcon :name="t('settings', 'Loading groups…')" />
</div>
</NcAppNavigationList>
</Fragment>
</template>
<script setup lang="ts">
import { computed, ref, watch, onBeforeMount } from 'vue'
import { Fragment } from 'vue-frag'
import { useRoute, useRouter } from 'vue-router/composables'
import { useElementVisibility } from '@vueuse/core'
import { showError } from '@nextcloud/dialogs'
import { mdiAccountGroup, mdiPlus } from '@mdi/js'
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
import NcActionText from '@nextcloud/vue/components/NcActionText'
import NcAppNavigationCaption from '@nextcloud/vue/components/NcAppNavigationCaption'
import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import GroupListItem from './GroupListItem.vue'
import { useFormatGroups } from '../composables/useGroupsNavigation.ts'
import { useStore } from '../store'
import { searchGroups } from '../service/groups.ts'
import logger from '../logger.ts'
const store = useStore()
const route = useRoute()
const router = useRouter()
onBeforeMount(async () => {
await loadGroups()
})
/** Current active group in the view - this is URL encoded */
const selectedGroup = computed(() => route.params?.selectedGroup)
/** Current active group - URL decoded */
const selectedGroupDecoded = computed(() => selectedGroup.value ? decodeURIComponent(selectedGroup.value) : null)
/** All available groups */
const groups = computed(() => store.getters.getSortedGroups)
/** User groups */
const { userGroups } = useFormatGroups(groups)
/** Server settings for current user */
const settings = computed(() => store.getters.getServerData)
/** True if the current user is a (delegated) admin */
const isAdminOrDelegatedAdmin = computed(() => settings.value.isAdmin || settings.value.isDelegatedAdmin)
/** True if the 'add-group' dialog is open - needed to be able to close it when the group is created */
const isAddGroupOpen = ref(false)
/** True if the group creation is in progress to show loading spinner and disable adding another one */
const loadingAddGroup = ref(false)
/** Error state for creating a new group */
const hasAddGroupError = ref(false)
/** Name of the group to create (used in the group creation dialog) */
const newGroupName = ref('')
/** True if groups are loading */
const loadingGroups = ref(false)
/** Search offset */
const offset = ref(0)
/** Search query for groups */
const groupsSearchQuery = ref('')
const groupListItems = ref([])
const lastGroupListItem = computed(() => {
return groupListItems.value
.findLast(component => component?.$vnode?.key === userGroups.value?.at(-1)?.id) // Order of refs is not guaranteed to match source array order
?.$refs?.listItem?.$el
})
const isLastGroupVisible = useElementVisibility(lastGroupListItem)
watch(isLastGroupVisible, async () => {
if (!isLastGroupVisible.value) {
return
}
await loadGroups()
})
watch(groupsSearchQuery, async () => {
store.commit('resetGroups')
offset.value = 0
await loadGroups()
})
/** Cancelable promise for search groups request */
const promise = ref(null)
/**
* Load groups
*/
async function loadGroups() {
if (promise.value) {
promise.value.cancel()
}
loadingGroups.value = true
try {
promise.value = searchGroups({
search: groupsSearchQuery.value,
offset: offset.value,
limit: 25,
})
const groups = await promise.value
if (groups.length > 0) {
offset.value += 25
}
for (const group of groups) {
store.commit('addGroup', group)
}
} catch (error) {
logger.error(t('settings', 'Failed to load groups'), { error })
}
promise.value = null
loadingGroups.value = false
}
/**
* Create a new group
*/
async function createGroup() {
hasAddGroupError.value = false
const groupId = newGroupName.value.trim()
if (groupId === '') {
hasAddGroupError.value = true
return
}
isAddGroupOpen.value = false
loadingAddGroup.value = true
try {
await store.dispatch('addGroup', groupId)
await router.push({
name: 'group',
params: {
selectedGroup: encodeURIComponent(groupId),
},
})
const newGroupListItem = groupListItems.value.findLast(component => component?.$vnode?.key === groupId)
newGroupListItem?.$refs?.listItem?.$el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
newGroupName.value = ''
} catch {
showError(t('settings', 'Failed to create group'))
}
loadingAddGroup.value = false
}
</script>

@ -29,6 +29,7 @@
</NcModal>
<NcAppNavigationItem :key="id"
ref="listItem"
:exact="true"
:name="name"
:to="{ name: 'group', params: { selectedGroup: encodeURIComponent(id) } }"

@ -34,8 +34,6 @@
users,
settings,
hasObfuscated,
groups,
subAdminsGroups,
quotaOptions,
languages,
externalActions,
@ -173,15 +171,8 @@ export default {
},
groups() {
// data provided php side + remove the recent and disabled groups
return this.$store.getters.getGroups
return this.$store.getters.getSortedGroups
.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
},
quotaOptions() {

@ -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))" />
<!-- If user is not admin, they are a subadmin.
Subadmins can't create users outside their groups
Therefore, empty select is forbidden -->
</div>
<div v-if="subAdminsGroups.length > 0"
class="dialog__item">
<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="subAdminsGroups"
:close-on-select="false"
:multiple="true"
label="name" />
label="name"
@search="searchGroups" />
</div>
<div class="dialog__item">
<NcSelect v-model="newUser.quota"
@ -142,6 +145,9 @@ import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { searchGroups } from '../../service/groups.ts'
import logger from '../../logger.ts'
export default {
name: 'NewUserDialog',
@ -172,11 +178,14 @@ export default {
data() {
return {
availableGroups: this.$store.getters.getSortedGroups.filter(group => 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)
},
/**

@ -42,7 +42,7 @@
scope="col">
<span>{{ t('settings', 'Groups') }}</span>
</th>
<th v-if="subAdminsGroups.length > 0 && (settings.isAdmin || settings.isDelegatedAdmin)"
<th v-if="settings.isAdmin || settings.isDelegatedAdmin"
class="header__cell header__cell--large"
data-cy-user-list-header-subadmins
scope="col">
@ -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

@ -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 @@
</span>
</td>
<td v-if="subAdminsGroups.length > 0 && (settings.isAdmin || settings.isDelegatedAdmin)"
<td 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) && subAdminsGroups.length > 0">
<template v-if="editing && (settings.isAdmin || settings.isDelegatedAdmin)">
<label class="hidden-visually"
:for="'subadmins' + uniqueId">
{{ t('settings', 'Set account as admin for') }}
@ -139,21 +140,22 @@
:data-loading="loading.subadmins || undefined"
:input-id="'subadmins' + uniqueId"
:close-on-select="false"
:disabled="isLoadingField"
:disabled="isLoadingField || loading.subAdminGroupsDetails"
:loading="loading.subadmins"
label="name"
:append-to-body="false"
:multiple="true"
:no-wrap="true"
:options="subAdminsGroups"
:options="availableSubAdminGroups"
:placeholder="t('settings', 'Set account as admin for')"
:value="userSubAdminsGroups"
:value="userSubAdminGroups"
@search="searchGroups"
@option:deselected="removeUserSubAdmin"
@option:selected="options => addUserSubAdmin(options.at(-1))" />
</template>
<span v-else-if="!isObfuscated"
:title="userSubAdminsGroupsLabels?.length > 40 ? userSubAdminsGroupsLabels : null">
{{ userSubAdminsGroupsLabels }}
:title="userSubAdminGroupsLabels?.length > 40 ? userSubAdminGroupsLabels : null">
{{ userSubAdminGroupsLabels }}
</span>
</td>
@ -296,6 +298,8 @@ import UserRowActions from './UserRowActions.vue'
import UserRowMixin from '../../mixins/UserRowMixin.js'
import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts'
import { searchGroups, loadUserGroups, loadUserSubAdminGroups } from '../../service/groups.ts'
import logger from '../../logger.ts'
export default {
name: 'UserRow',
@ -330,14 +334,6 @@ export default {
type: Boolean,
required: true,
},
groups: {
type: Array,
default: () => [],
},
subAdminsGroups: {
type: Array,
required: true,
},
quotaOptions: {
type: Array,
required: true,
@ -370,6 +366,8 @@ export default {
password: false,
mailAddress: false,
groups: false,
groupsDetails: false,
subAdminGroupsDetails: false,
subadmins: false,
quota: false,
delete: false,
@ -381,6 +379,8 @@ export default {
editedDisplayName: this.user.displayname,
editedPassword: '',
editedMail: this.user.email ?? '',
// Cancelable promise for search groups request
promise: null,
}
},
@ -412,13 +412,13 @@ export default {
userGroupsLabels() {
return this.userGroups
.map(group => group.name)
.map(group => group.name ?? group.id)
.join(', ')
},
userSubAdminsGroupsLabels() {
return this.userSubAdminsGroups
.map(group => group.name)
userSubAdminGroupsLabels() {
return this.userSubAdminGroups
.map(group => group.name ?? group.id)
.join(', ')
},
@ -554,6 +554,56 @@ export default {
this.loadingPossibleManagers = false
},
async loadGroupsDetails() {
this.loading.groups = true
this.loading.groupsDetails = true
try {
const groups = await loadUserGroups({ userId: this.user.id })
this.availableGroups = this.availableGroups.map(availableGroup => groups.find(group => group.id === availableGroup.id) ?? availableGroup)
} 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 })
this.availableSubAdminGroups = this.availableSubAdminGroups.map(availableGroup => groups.find(group => group.id === availableGroup.id) ?? availableGroup)
} catch (error) {
logger.error(t('settings', 'Failed to load subadmin 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
this.availableGroups = groups
this.availableSubAdminGroups = groups.filter(group => group.id !== 'admin')
} 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)) : []
@ -700,17 +750,18 @@ export default {
* @param {string} gid Group id
*/
async createGroup({ name: gid }) {
this.loading = { groups: true, subadmins: true }
this.loading.groups = true
try {
await this.$store.dispatch('addGroup', gid)
this.availableGroups.push({ id: gid, name: gid })
this.availableSubAdminGroups.push({ id: gid, name: gid })
const userid = this.user.id
await this.$store.dispatch('addUserGroup', { userid, gid })
this.userGroups.push({ id: gid, name: gid })
} catch (error) {
console.error(error)
} finally {
this.loading = { groups: false, subadmins: false }
logger.error(t('settings', 'Failed to create group'), { error })
}
return this.$store.getters.getGroups[this.groups.length]
this.loading.groups = false
},
/**
@ -724,19 +775,19 @@ export default {
// Ignore
return
}
this.loading.groups = true
const userid = this.user.id
const gid = group.id
if (group.canAdd === false) {
return false
return
}
this.loading.groups = true
try {
await this.$store.dispatch('addUserGroup', { userid, gid })
this.userGroups.push(group)
} catch (error) {
console.error(error)
} finally {
this.loading.groups = false
}
this.loading.groups = false
},
/**
@ -756,6 +807,7 @@ export default {
userid,
gid,
})
this.userGroups = this.userGroups.filter(group => group.id !== gid)
this.loading.groups = false
// remove user from current list if current list is the removed group
if (this.$route.params.selectedGroup === gid) {
@ -780,10 +832,11 @@ export default {
userid,
gid,
})
this.loading.subadmins = false
this.userSubAdminGroups.push(group)
} catch (error) {
console.error(error)
}
this.loading.subadmins = false
},
/**
@ -801,6 +854,7 @@ export default {
userid,
gid,
})
this.userSubAdminGroups = this.userSubAdminGroups.filter(group => group.id !== gid)
} catch (error) {
console.error(error)
} finally {
@ -901,6 +955,8 @@ export default {
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

@ -43,6 +43,9 @@
</NcNoteCard>
<fieldset>
<legend>{{ t('settings', 'Group list sorting') }}</legend>
<NcNoteCard class="dialog__note"
type="info"
:text="t('settings', 'Sorting only applies to the currently loaded groups for performance reasons. Groups will be loaded as you navigate or search through the list.')" />
<NcCheckboxRadioSwitch type="radio"
:checked.sync="groupSorting"
data-test="sortGroupsByMemberCount"
@ -322,6 +325,12 @@ export default {
</script>
<style scoped lang="scss">
.dialog {
&__note {
font-weight: normal;
}
}
fieldset {
font-weight: bold;
}

@ -16,14 +16,6 @@ export default {
type: Object,
default: () => ({}),
},
groups: {
type: Array,
default: () => [],
},
subAdminsGroups: {
type: Array,
default: () => [],
},
quotaOptions: {
type: Array,
default: () => [],
@ -49,38 +41,19 @@ export default {
formattedFullTime,
}
},
data() {
return {
availableGroups: this.user.groups.map(id => ({ id, name: id })),
availableSubAdminGroups: this.user.subadmin.map(id => ({ id, name: id })),
userGroups: this.user.groups.map(id => ({ id, name: id })),
userSubAdminGroups: this.user.subadmin.map(id => ({ id, name: id })),
}
},
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
/* GROUPS MANAGEMENT */
userGroups() {
const userGroups = this.groups.filter(group => this.user.groups.includes(group.id))
return userGroups
},
userSubAdminsGroups() {
const userSubAdminsGroups = this.subAdminsGroups.filter(group => this.user.subadmin.includes(group.id))
return userSubAdminsGroups
},
availableGroups() {
return this.groups.map((group) => {
// clone object because we don't want
// to edit the original groups
const groupClone = Object.assign({}, group)
// two settings here:
// 1. user NOT in group but no permission to add
// 2. user is in group but no permission to remove
groupClone.$isDisabled
= (group.canAdd === false
&& !this.user.groups.includes(group.id))
|| (group.canRemove === false
&& this.user.groups.includes(group.id))
return groupClone
})
},
/* QUOTA MANAGEMENT */
usedSpace() {
const quotaUsed = this.user.quota.used > 0 ? this.user.quota.used : 0

@ -0,0 +1,83 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IGroup } from '../views/user-types.d.ts'
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { CancelablePromise } from 'cancelable-promise'
interface Group {
id: string
displayname: string
usercount: number
disabled: number
canAdd: boolean
canRemove: boolean
}
const formatGroup = (group: Group): Required<IGroup> => ({
id: group.id,
name: group.displayname,
usercount: group.usercount,
disabled: group.disabled,
canAdd: group.canAdd,
canRemove: group.canRemove,
})
/**
* Search groups
*
* @param {object} options Options
* @param {string} options.search Search query
* @param {number} options.offset Offset
* @param {number} options.limit Limit
*/
export const searchGroups = ({ search, offset, limit }): CancelablePromise<Required<IGroup>[]> => {
const controller = new AbortController()
return new CancelablePromise(async (resolve, reject, onCancel) => {
onCancel(() => controller.abort())
try {
const { data } = await axios.get(
generateOcsUrl('/cloud/groups/details?search={search}&offset={offset}&limit={limit}', { search, offset, limit }), {
signal: controller.signal,
},
)
const groups: Group[] = data.ocs?.data?.groups ?? []
const formattedGroups = groups.map(formatGroup)
resolve(formattedGroups)
} catch (error) {
reject(error)
}
})
}
/**
* Load user groups
*
* @param {object} options Options
* @param {string} options.userId User id
*/
export const loadUserGroups = async ({ userId }): Promise<Required<IGroup>[]> => {
const url = generateOcsUrl('/cloud/users/{userId}/groups/details', { userId })
const { data } = await axios.get(url)
const groups: Group[] = data.ocs?.data?.groups ?? []
const formattedGroups = groups.map(formatGroup)
return formattedGroups
}
/**
* Load user subadmin groups
*
* @param {object} options Options
* @param {string} options.userId User id
*/
export const loadUserSubAdminGroups = async ({ userId }): Promise<Required<IGroup>[]> => {
const url = generateOcsUrl('/cloud/users/{userId}/subadmins/details', { userId })
const { data } = await axios.get(url)
const groups: Group[] = data.ocs?.data?.groups ?? []
const formattedGroups = groups.map(formatGroup)
return formattedGroups
}

@ -8,15 +8,22 @@ import { getCapabilities } from '@nextcloud/capabilities'
import { parseFileSize } from '@nextcloud/files'
import { showError } from '@nextcloud/dialogs'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import axios from '@nextcloud/axios'
import { GroupSorting } from '../constants/GroupManagement.ts'
import { naturalCollator } from '../utils/sorting.ts'
import api from './api.js'
import logger from '../logger.ts'
const usersSettings = loadState('settings', 'usersSettings', {})
const localStorage = getBuilder('settings').persist(true).build()
const defaults = {
/**
* @type {import('../views/user-types').IGroup}
*/
group: {
id: '',
name: '',
@ -29,14 +36,14 @@ const defaults = {
const state = {
users: [],
groups: [],
orderBy: GroupSorting.UserCount,
groups: [...(usersSettings.systemGroups ?? [])],
orderBy: usersSettings.sortGroups ?? GroupSorting.UserCount,
minPasswordLength: 0,
usersOffset: 0,
usersLimit: 25,
disabledUsersOffset: 0,
disabledUsersLimit: 25,
userCount: 0,
userCount: usersSettings.userCount ?? 0,
showConfig: {
showStoragePath: localStorage.getItem('account_settings__showStoragePath') === 'true',
showUserBackend: localStorage.getItem('account_settings__showUserBackend') === 'true',
@ -63,21 +70,17 @@ const mutations = {
setPasswordPolicyMinLength(state, length) {
state.minPasswordLength = length !== '' ? length : 0
},
initGroups(state, { groups, orderBy, userCount }) {
state.groups = groups.map(group => Object.assign({}, defaults.group, group))
state.orderBy = orderBy
state.userCount = userCount
},
addGroup(state, { gid, displayName }) {
/**
* @param {object} state store state
* @param {import('../views/user-types.js').IGroup} newGroup new group
*/
addGroup(state, newGroup) {
try {
if (typeof state.groups.find((group) => group.id === gid) !== 'undefined') {
if (typeof state.groups.find((group) => group.id === newGroup.id) !== 'undefined') {
return
}
// extend group to default values
const group = Object.assign({}, defaults.group, {
id: gid,
name: displayName,
})
const group = Object.assign({}, defaults.group, newGroup)
state.groups.unshift(group)
} catch (e) {
console.error('Can\'t create group', e)
@ -157,6 +160,9 @@ const mutations = {
state.userCount += user.enabled ? 1 : -1 // update Active Users count
user.groups.forEach(userGroup => {
const group = state.groups.find(groupSearch => groupSearch.id === userGroup)
if (!group) {
return
}
group.disabled += user.enabled ? -1 : 1 // update group disabled count
})
break
@ -165,9 +171,11 @@ const mutations = {
state.userCount++ // increment Active Users count
user.groups.forEach(userGroup => {
state.groups
.find(groupSearch => groupSearch.id === userGroup)
.usercount++ // increment group total count
const group = state.groups.find(groupSearch => groupSearch.id === userGroup)
if (!group) {
return
}
group.usercount++ // increment group total count
})
break
case 'remove':
@ -186,6 +194,9 @@ const mutations = {
disabledGroup.usercount-- // decrement Disabled Users count
user.groups.forEach(userGroup => {
const group = state.groups.find(groupSearch => groupSearch.id === userGroup)
if (!group) {
return
}
group.disabled-- // decrement group disabled count
})
}
@ -215,6 +226,20 @@ const mutations = {
state.disabledUsersOffset = 0
},
/**
* Reset group list
*
* @param {object} state the store state
*/
resetGroups(state) {
const systemGroups = state.groups.filter(group => [
'admin',
'__nc_internal_recent',
'disabled',
].includes(group.id))
state.groups = [...systemGroups]
},
setShowConfig(state, { key, value }) {
localStorage.setItem(`account_settings__${key}`, JSON.stringify(value))
state.showConfig[key] = value
@ -245,20 +270,16 @@ const getters = {
getGroups(state) {
return state.groups
},
getSubadminGroups(state) {
// Can't be subadmin of admin, recent, or disabled
return state.groups.filter(group => group.id !== 'admin' && group.id !== '__nc_internal_recent' && group.id !== 'disabled')
},
getSortedGroups(state) {
const groups = [...state.groups]
if (state.orderBy === GroupSorting.UserCount) {
return groups.sort((a, b) => {
const numA = a.usercount - a.disabled
const numB = b.usercount - b.disabled
return (numA < numB) ? 1 : (numB < numA ? -1 : a.name.localeCompare(b.name))
return (numA < numB) ? 1 : (numB < numA ? -1 : naturalCollator.compare(a.name, b.name))
})
} else {
return groups.sort((a, b) => a.name.localeCompare(b.name))
return groups.sort((a, b) => naturalCollator.compare(a.name, b.name))
}
},
getGroupSorting(state) {
@ -444,7 +465,7 @@ const actions = {
.then((response) => {
if (Object.keys(response.data.ocs.data.groups).length > 0) {
response.data.ocs.data.groups.forEach(function(group) {
context.commit('addGroup', { gid: group, displayName: group })
context.commit('addGroup', { id: group, name: group })
})
return true
}
@ -511,7 +532,7 @@ const actions = {
return api.requireAdmin().then((response) => {
return api.post(generateOcsUrl('cloud/groups'), { groupid: gid })
.then((response) => {
context.commit('addGroup', { gid, displayName: gid })
context.commit('addGroup', { id: gid, name: gid })
return { gid, displayName: gid }
})
.catch((error) => { throw error })

@ -0,0 +1,14 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
export const naturalCollator = Intl.Collator(
[getLanguage(), getCanonicalLocale()],
{
numeric: true,
usage: 'sort',
},
)

@ -55,11 +55,6 @@ export default defineComponent({
},
beforeMount() {
this.$store.commit('initGroups', {
groups: this.$store.getters.getServerData.groups,
orderBy: this.$store.getters.getServerData.sortGroups,
userCount: this.$store.getters.getServerData.userCount,
})
this.$store.dispatch('getPasswordPolicyMinLength')
},

@ -3,7 +3,8 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcAppNavigation :aria-label="t('settings', 'Account management')">
<NcAppNavigation class="account-management__navigation"
:aria-label="t('settings', 'Account management')">
<NcAppNavigationNew button-id="new-user-button"
:text="t('settings','New account')"
@click="showNewUserMenu"
@ -79,42 +80,7 @@
</NcAppNavigationItem>
</NcAppNavigationList>
<NcAppNavigationCaption :name="t('settings', 'Groups')"
:disabled="loadingAddGroup"
:aria-label="loadingAddGroup ? t('settings', 'Creating group…') : t('settings', 'Create group')"
force-menu
is-heading
:open.sync="isAddGroupOpen">
<template v-if="isAdminOrDelegatedAdmin" #actionsTriggerIcon>
<NcLoadingIcon v-if="loadingAddGroup" />
<NcIconSvgWrapper v-else :path="mdiPlus" />
</template>
<template v-if="isAdminOrDelegatedAdmin" #actions>
<NcActionText>
<template #icon>
<NcIconSvgWrapper :path="mdiAccountGroup" />
</template>
{{ t('settings', 'Create group') }}
</NcActionText>
<NcActionInput :label="t('settings', 'Group name')"
data-cy-users-settings-new-group-name
:label-outside="false"
:disabled="loadingAddGroup"
:value.sync="newGroupName"
:error="hasAddGroupError"
:helper-text="hasAddGroupError ? t('settings', 'Please enter a valid group name') : ''"
@submit="createGroup" />
</template>
</NcAppNavigationCaption>
<NcAppNavigationList class="account-management__group-list" data-cy-users-settings-navigation-groups="custom">
<GroupListItem v-for="group in userGroups"
:id="group.id"
:key="group.id"
:active="selectedGroupDecoded === group.id"
:name="group.title"
:count="group.count" />
</NcAppNavigationList>
<AppNavigationGroupList />
<template #footer>
<NcButton class="account-management__settings-toggle"
@ -131,31 +97,26 @@
</template>
<script setup lang="ts">
import { mdiAccount, mdiAccountGroup, mdiAccountOff, mdiCog, mdiPlus, mdiShieldAccount, mdiHistory } from '@mdi/js'
import { showError } from '@nextcloud/dialogs'
import { mdiAccount, mdiAccountOff, mdiCog, mdiPlus, mdiShieldAccount, mdiHistory } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import { computed, ref } from 'vue'
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
import NcActionText from '@nextcloud/vue/components/NcActionText'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationCaption from '@nextcloud/vue/components/NcAppNavigationCaption'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
import NcAppNavigationNew from '@nextcloud/vue/components/NcAppNavigationNew'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import GroupListItem from '../components/GroupListItem.vue'
import UserSettingsDialog from '../components/Users/UserSettingsDialog.vue'
import AppNavigationGroupList from '../components/AppNavigationGroupList.vue'
import { useStore } from '../store'
import { useRoute, useRouter } from 'vue-router/composables'
import { useRoute } from 'vue-router/composables'
import { useFormatGroups } from '../composables/useGroupsNavigation'
const route = useRoute()
const router = useRouter()
const store = useStore()
/** State of the 'new-account' dialog */
@ -170,51 +131,13 @@ const selectedGroupDecoded = computed(() => selectedGroup.value ? decodeURICompo
const userCount = computed(() => store.getters.getUserCount)
/** All available groups */
const groups = computed(() => store.getters.getSortedGroups)
const { adminGroup, recentGroup, disabledGroup, userGroups } = useFormatGroups(groups)
const { adminGroup, recentGroup, disabledGroup } = useFormatGroups(groups)
/** Server settings for current user */
const settings = computed(() => store.getters.getServerData)
/** True if the current user is a (delegated) admin */
const isAdminOrDelegatedAdmin = computed(() => settings.value.isAdmin || settings.value.isDelegatedAdmin)
/** True if the 'add-group' dialog is open - needed to be able to close it when the group is created */
const isAddGroupOpen = ref(false)
/** True if the group creation is in progress to show loading spinner and disable adding another one */
const loadingAddGroup = ref(false)
/** Error state for creating a new group */
const hasAddGroupError = ref(false)
/** Name of the group to create (used in the group creation dialog) */
const newGroupName = ref('')
/**
* Create a new group
*/
async function createGroup() {
hasAddGroupError.value = false
const groupId = newGroupName.value.trim()
if (groupId === '') {
hasAddGroupError.value = true
return
}
isAddGroupOpen.value = false
loadingAddGroup.value = true
try {
await store.dispatch('addGroup', groupId)
await router.push({
name: 'group',
params: {
selectedGroup: encodeURIComponent(groupId),
},
})
newGroupName.value = ''
} catch {
showError(t('settings', 'Failed to create group'))
}
loadingAddGroup.value = false
}
/**
* Open the new-user form dialog
*/
@ -227,7 +150,12 @@ function showNewUserMenu() {
</script>
<style scoped lang="scss">
.account-management{
.account-management {
&__navigation {
:deep(.app-navigation__body) {
will-change: scroll-position;
}
}
&__system-list {
height: auto !important;
overflow: visible !important;

@ -3,7 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export interface IGroup {
/**
* Id
*/
id: string
/**
* Display name
*/
name: string
/**
@ -15,4 +22,14 @@ export interface IGroup {
* Number of disabled users
*/
disabled: number
/**
* True if users can be added to this group
*/
canAdd?: boolean
/**
* True if users can be removed from this group
*/
canRemove?: boolean
}

@ -55,15 +55,17 @@ describe('Settings: Assign user to a group', { testIsolation: false }, () => {
})
cy.runOccCommand(`group:add '${groupName}'`)
cy.login(admin)
cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
cy.visit('/settings/users')
cy.wait('@loadGroups')
})
it('see that the group is in the list', () => {
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').contains('li', groupName).should('exist')
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').contains('li', groupName).within(() => {
cy.get('.counter-bubble__counter')
.should('not.exist') // is hidden when 0
})
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.should('exist')
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.find('.counter-bubble__counter')
.should('not.exist') // is hidden when 0
})
it('see that the user is in the list', () => {
@ -101,8 +103,7 @@ describe('Settings: Assign user to a group', { testIsolation: false }, () => {
it('see the group was successfully assigned', () => {
// see a new memeber
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
.contains('li', groupName)
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.find('.counter-bubble__counter')
.should('contain', '1')
})
@ -121,18 +122,20 @@ describe('Settings: Delete an empty group', { testIsolation: false }, () => {
before(() => {
cy.runOccCommand(`group:add '${groupName}'`)
cy.login(admin)
cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
cy.visit('/settings/users')
cy.wait('@loadGroups')
})
it('see that the group is in the list', () => {
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
// see that the list of groups contains the group foo
cy.contains(groupName).should('exist').scrollIntoView()
// open the actions menu for the group
cy.contains('li', groupName).within(() => {
cy.get('button.action-item__menutoggle').click({ force: true })
})
})
// see that the list of groups contains the group foo
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.should('exist')
.scrollIntoView()
// open the actions menu for the group
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.find('button.action-item__menutoggle')
.click({ force: true })
})
it('can delete the group', () => {
@ -146,10 +149,9 @@ describe('Settings: Delete an empty group', { testIsolation: false }, () => {
})
it('deleted group is not shown anymore', () => {
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
// see that the list of groups does not contain the group
cy.contains(groupName).should('not.exist')
})
// see that the list of groups does not contain the group
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.should('not.exist')
// and also not in database
cy.runOccCommand('group:list --output=json').then(($response) => {
const groups: string[] = Object.keys(JSON.parse($response.stdout))
@ -169,19 +171,22 @@ describe('Settings: Delete a non empty group', () => {
cy.runOccCommand(`group:addUser '${groupName}' '${$user.userId}'`)
})
cy.login(admin)
cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
cy.visit('/settings/users')
cy.wait('@loadGroups')
})
after(() => cy.deleteUser(testUser))
it('see that the group is in the list', () => {
// see that the list of groups contains the group
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').contains('li', groupName).should('exist').scrollIntoView()
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.should('exist')
.scrollIntoView()
})
it('can delete the group', () => {
// open the menu
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
.contains('li', groupName)
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.find('button.action-item__menutoggle')
.click({ force: true })
@ -195,10 +200,9 @@ describe('Settings: Delete a non empty group', () => {
})
it('deleted group is not shown anymore', () => {
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
// see that the list of groups does not contain the group foo
cy.contains(groupName).should('not.exist')
})
// see that the list of groups does not contain the group foo
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
.should('not.exist')
// and also not in database
cy.runOccCommand('group:list --output=json').then(($response) => {
const groups: string[] = Object.keys(JSON.parse($response.stdout))

@ -239,6 +239,8 @@ describe('Settings: Change user properties', function() {
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 })

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

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

@ -26005,6 +26005,168 @@
}
}
},
"/ocs/v2.php/cloud/users/{userId}/groups/details": {
"get": {
"operationId": "provisioning_api-full-users-get-users-groups-details",
"summary": "Get a list of groups with details",
"tags": [
"provisioning_api-full/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/ProvisioningApi-fullGroupDetails"
}
}
}
}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/cloud/users/{userId}/subadmins/details": {
"get": {
"operationId": "provisioning_api-full-users-get-user-sub-admin-groups-details",
"summary": "Get a list of the groups the user is a subadmin of, with details",
"tags": [
"provisioning_api-full/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/ProvisioningApi-fullGroupDetails"
}
}
}
}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/cloud/users/{userId}/welcome": {
"post": {
"operationId": "provisioning_api-full-users-resend-welcome-message",