Skip to content

Commit b3a736e

Browse files
feat(settings): admin UI for managing nested groups
Add a GroupNestingModal reachable from each row in the groups list via a new "Manage nested groups" action. The modal uses NcSelect pickers backed by the existing searchGroups service to add or remove subgroups and admin groups, excluding self and already-added entries from autocomplete. Store actions fetchSubGroups / addSubGroup / removeSubGroup and fetchGroupSubAdmins / addGroupSubAdmin / removeGroupSubAdmin wrap the new OCS endpoints. Signed-off-by: Kiara Grouwstra <cinereal@riseup.net>
1 parent d60faa5 commit b3a736e

7 files changed

Lines changed: 422 additions & 6 deletions

apps/settings/src/components/GroupListItem.vue

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55

66
<template>
77
<Fragment>
8+
<GroupNestingModal
9+
v-if="showNestingModal"
10+
:group-id="id"
11+
:group-name="name"
12+
@close="showNestingModal = false" />
813
<NcModal
914
v-if="showRemoveGroupModal"
1015
@close="showRemoveGroupModal = false">
@@ -64,6 +69,14 @@
6469
<Pencil :size="20" />
6570
</template>
6671
</NcActionInput>
72+
<NcActionButton
73+
v-if="id !== 'admin' && id !== 'disabled' && (settings.isAdmin || settings.isDelegatedAdmin)"
74+
@click="showNestingModal = true">
75+
<template #icon>
76+
<FamilyTree :size="20" />
77+
</template>
78+
{{ t('settings', 'Manage nested groups') }}
79+
</NcActionButton>
6780
<NcActionButton
6881
v-if="id !== 'admin' && id !== 'disabled' && (settings.isAdmin || settings.isDelegatedAdmin)"
6982
@click="showRemoveGroupModal = true">
@@ -88,15 +101,19 @@ import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
88101
import NcModal from '@nextcloud/vue/components/NcModal'
89102
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
90103
import AccountGroup from 'vue-material-design-icons/AccountGroupOutline.vue'
104+
import FamilyTree from 'vue-material-design-icons/FileTreeOutline.vue'
91105
import Pencil from 'vue-material-design-icons/PencilOutline.vue'
92106
import Delete from 'vue-material-design-icons/TrashCanOutline.vue'
107+
import GroupNestingModal from './GroupNestingModal.vue'
93108
94109
export default {
95110
name: 'GroupListItem',
96111
components: {
97112
AccountGroup,
98113
Delete,
114+
FamilyTree,
99115
Fragment,
116+
GroupNestingModal,
100117
NcActionButton,
101118
NcActionInput,
102119
NcAppNavigationItem,
@@ -146,6 +163,7 @@ export default {
146163
loadingRenameGroup: false,
147164
openGroupMenu: false,
148165
showRemoveGroupModal: false,
166+
showNestingModal: false,
149167
}
150168
},
151169
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<NcModal @close="$emit('close')">
8+
<div class="nesting-modal">
9+
<h2 class="nesting-modal__header">
10+
{{ t('settings', 'Nested groups for "{group}"', { group: groupName }) }}
11+
</h2>
12+
13+
<section class="nesting-modal__section">
14+
<h3>{{ t('settings', 'Subgroups') }}</h3>
15+
<p class="nesting-modal__hint">
16+
{{ t('settings', 'Members of a subgroup are treated as effective members of this group for shares, permissions and app restrictions.') }}
17+
</p>
18+
19+
<ul v-if="subGroups.length" class="nesting-modal__list">
20+
<li v-for="child in subGroups" :key="'sub-' + child">
21+
<span>{{ child }}</span>
22+
<NcButton variant="tertiary" @click="removeSubGroup(child)">
23+
<template #icon>
24+
<Delete :size="16" />
25+
</template>
26+
{{ t('settings', 'Remove') }}
27+
</NcButton>
28+
</li>
29+
</ul>
30+
<p v-else class="nesting-modal__empty">
31+
{{ t('settings', 'No direct subgroups.') }}
32+
</p>
33+
34+
<div class="nesting-modal__form">
35+
<NcSelect
36+
ref="subGroupPicker"
37+
class="nesting-modal__picker"
38+
:input-label="t('settings', 'Add subgroup')"
39+
:placeholder="t('settings', 'Search for a group…')"
40+
:options="subGroupOptions"
41+
:loading="subGroupLoading"
42+
:model-value="pendingSubGroup"
43+
label="name"
44+
@search="onSearchSubGroup"
45+
@update:model-value="onPickSubGroup" />
46+
</div>
47+
</section>
48+
49+
<section class="nesting-modal__section">
50+
<h3>{{ t('settings', 'Admin groups') }}</h3>
51+
<p class="nesting-modal__hint">
52+
{{ t('settings', 'Members of an admin group gain sub-admin rights over this group and all of its subgroups.') }}
53+
</p>
54+
55+
<ul v-if="adminGroups.length" class="nesting-modal__list">
56+
<li v-for="admin in adminGroups" :key="'admin-' + admin">
57+
<span>{{ admin }}</span>
58+
<NcButton variant="tertiary" @click="removeAdminGroup(admin)">
59+
<template #icon>
60+
<Delete :size="16" />
61+
</template>
62+
{{ t('settings', 'Remove') }}
63+
</NcButton>
64+
</li>
65+
</ul>
66+
<p v-else class="nesting-modal__empty">
67+
{{ t('settings', 'No admin groups.') }}
68+
</p>
69+
70+
<div class="nesting-modal__form">
71+
<NcSelect
72+
ref="adminGroupPicker"
73+
class="nesting-modal__picker"
74+
:input-label="t('settings', 'Add admin group')"
75+
:placeholder="t('settings', 'Search for a group…')"
76+
:options="adminGroupOptions"
77+
:loading="adminGroupLoading"
78+
:model-value="pendingAdminGroup"
79+
label="name"
80+
@search="onSearchAdminGroup"
81+
@update:model-value="onPickAdminGroup" />
82+
</div>
83+
</section>
84+
</div>
85+
</NcModal>
86+
</template>
87+
88+
<script>
89+
import { showError, showSuccess } from '@nextcloud/dialogs'
90+
import NcButton from '@nextcloud/vue/components/NcButton'
91+
import NcModal from '@nextcloud/vue/components/NcModal'
92+
import NcSelect from '@nextcloud/vue/components/NcSelect'
93+
import Delete from 'vue-material-design-icons/TrashCanOutline.vue'
94+
import { searchGroups } from '../service/groups.ts'
95+
96+
export default {
97+
name: 'GroupNestingModal',
98+
components: {
99+
Delete,
100+
NcButton,
101+
NcModal,
102+
NcSelect,
103+
},
104+
105+
props: {
106+
groupId: {
107+
type: String,
108+
required: true,
109+
},
110+
groupName: {
111+
type: String,
112+
required: true,
113+
},
114+
},
115+
116+
emits: ['close'],
117+
118+
data() {
119+
return {
120+
subGroups: [],
121+
adminGroups: [],
122+
subGroupOptions: [],
123+
adminGroupOptions: [],
124+
subGroupLoading: false,
125+
adminGroupLoading: false,
126+
pendingSubGroup: null,
127+
pendingAdminGroup: null,
128+
searchPromise: null,
129+
}
130+
},
131+
132+
async mounted() {
133+
await Promise.all([this.refreshSubGroups(), this.refreshAdminGroups()])
134+
},
135+
136+
methods: {
137+
async refreshSubGroups() {
138+
try {
139+
this.subGroups = await this.$store.dispatch('fetchSubGroups', this.groupId)
140+
} catch (e) {
141+
showError(t('settings', 'Failed to load subgroups'))
142+
}
143+
},
144+
async refreshAdminGroups() {
145+
try {
146+
this.adminGroups = await this.$store.dispatch('fetchGroupSubAdmins', this.groupId)
147+
} catch (e) {
148+
showError(t('settings', 'Failed to load admin groups'))
149+
}
150+
},
151+
async doSearch(query, target) {
152+
// Shared autocomplete for both pickers; excludes self and already-added entries.
153+
const excludes = target === 'sub'
154+
? new Set([this.groupId, ...this.subGroups])
155+
: new Set([this.groupId, ...this.adminGroups])
156+
const toggleLoading = target === 'sub'
157+
? (v) => { this.subGroupLoading = v }
158+
: (v) => { this.adminGroupLoading = v }
159+
toggleLoading(true)
160+
try {
161+
if (this.searchPromise) {
162+
this.searchPromise.cancel()
163+
}
164+
this.searchPromise = searchGroups({ search: query ?? '', offset: 0, limit: 25 })
165+
const results = await this.searchPromise
166+
const filtered = results.filter((g) => !excludes.has(g.id))
167+
if (target === 'sub') {
168+
this.subGroupOptions = filtered
169+
} else {
170+
this.adminGroupOptions = filtered
171+
}
172+
} catch (e) {
173+
// cancelation or network error — leave options alone
174+
} finally {
175+
toggleLoading(false)
176+
}
177+
},
178+
onSearchSubGroup(query) {
179+
this.doSearch(query, 'sub')
180+
},
181+
onSearchAdminGroup(query) {
182+
this.doSearch(query, 'admin')
183+
},
184+
async onPickSubGroup(group) {
185+
if (!group) {
186+
return
187+
}
188+
try {
189+
await this.$store.dispatch('addSubGroup', {
190+
gid: this.groupId,
191+
subGroupId: group.id,
192+
})
193+
showSuccess(t('settings', 'Added subgroup "{name}"', { name: group.name ?? group.id }))
194+
this.pendingSubGroup = null
195+
this.subGroupOptions = []
196+
await this.refreshSubGroups()
197+
} catch (e) {
198+
const message = e?.response?.data?.ocs?.meta?.message
199+
showError(message || t('settings', 'Failed to add subgroup (cycle or missing group?)'))
200+
}
201+
},
202+
async removeSubGroup(childId) {
203+
try {
204+
await this.$store.dispatch('removeSubGroup', {
205+
gid: this.groupId,
206+
subGroupId: childId,
207+
})
208+
await this.refreshSubGroups()
209+
} catch (e) {
210+
showError(t('settings', 'Failed to remove subgroup'))
211+
}
212+
},
213+
async onPickAdminGroup(group) {
214+
if (!group) {
215+
return
216+
}
217+
try {
218+
await this.$store.dispatch('addGroupSubAdmin', {
219+
gid: this.groupId,
220+
adminGroupId: group.id,
221+
})
222+
showSuccess(t('settings', 'Added admin group "{name}"', { name: group.name ?? group.id }))
223+
this.pendingAdminGroup = null
224+
this.adminGroupOptions = []
225+
await this.refreshAdminGroups()
226+
} catch (e) {
227+
showError(t('settings', 'Failed to add admin group'))
228+
}
229+
},
230+
async removeAdminGroup(adminId) {
231+
try {
232+
await this.$store.dispatch('removeGroupSubAdmin', {
233+
gid: this.groupId,
234+
adminGroupId: adminId,
235+
})
236+
await this.refreshAdminGroups()
237+
} catch (e) {
238+
showError(t('settings', 'Failed to remove admin group'))
239+
}
240+
},
241+
},
242+
}
243+
</script>
244+
245+
<style lang="scss" scoped>
246+
.nesting-modal {
247+
display: flex;
248+
flex-direction: column;
249+
padding: 20px;
250+
gap: 16px;
251+
min-width: 480px;
252+
253+
&__header {
254+
margin: 0;
255+
}
256+
257+
&__section {
258+
display: flex;
259+
flex-direction: column;
260+
gap: 8px;
261+
}
262+
263+
&__hint {
264+
opacity: 0.8;
265+
font-size: 0.9em;
266+
}
267+
268+
&__list {
269+
list-style: none;
270+
padding: 0;
271+
margin: 0;
272+
273+
li {
274+
display: flex;
275+
align-items: center;
276+
justify-content: space-between;
277+
padding: 4px 0;
278+
}
279+
}
280+
281+
&__empty {
282+
opacity: 0.6;
283+
font-style: italic;
284+
}
285+
286+
&__form {
287+
display: flex;
288+
align-items: flex-end;
289+
gap: 8px;
290+
}
291+
292+
&__picker {
293+
flex: 1;
294+
}
295+
}
296+
</style>

0 commit comments

Comments
 (0)