Skip to content

Commit fb8cff3

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 311a7b1 commit fb8cff3

101 files changed

Lines changed: 2372 additions & 322 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

0 commit comments

Comments
 (0)