Skip to content

Commit 4bf376d

Browse files
committed
feat: add approval groups rule editor
Signed-off-by: Vitor Mattos <[email protected]>
1 parent e2a2247 commit 4bf376d

1 file changed

Lines changed: 185 additions & 0 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<div class="approval-groups-editor">
8+
<p class="approval-groups-editor__hint">
9+
{{ t('libresign', 'Members of the selected groups can approve submitted identification documents. Admin group members can always approve.') }}
10+
</p>
11+
12+
<NcSelect
13+
:model-value="selectedGroups"
14+
label="displayname"
15+
:no-wrap="false"
16+
:aria-label-combobox="t('libresign', 'Select groups allowed to approve identification documents')"
17+
:close-on-select="false"
18+
:disabled="loadingGroups"
19+
:loading="loadingGroups"
20+
:multiple="true"
21+
:options="availableGroups"
22+
:searchable="true"
23+
:show-no-options="false"
24+
@search-change="searchGroup"
25+
@update:modelValue="onGroupsChange" />
26+
</div>
27+
</template>
28+
29+
<script setup lang="ts">
30+
import axios from '@nextcloud/axios'
31+
import { getCurrentUser } from '@nextcloud/auth'
32+
import { loadState } from '@nextcloud/initial-state'
33+
import { t } from '@nextcloud/l10n'
34+
import { generateOcsUrl } from '@nextcloud/router'
35+
import { computed, onMounted, ref, watch } from 'vue'
36+
37+
import NcSelect from '@nextcloud/vue/components/NcSelect'
38+
39+
import logger from '../../../../../logger.js'
40+
import type { EffectivePolicyValue } from '../../../../../types/index'
41+
import { resolveApprovalGroups, serializeApprovalGroups } from './model'
42+
43+
type GroupRow = {
44+
id: string
45+
displayname: string
46+
}
47+
48+
type GroupDetailsResponse = {
49+
ocs?: {
50+
data?: {
51+
groups?: Array<{
52+
id: string
53+
displayname?: string
54+
}>
55+
}
56+
}
57+
}
58+
59+
type GroupListResponse = {
60+
ocs?: {
61+
data?: {
62+
groups?: string[]
63+
}
64+
}
65+
}
66+
67+
defineOptions({
68+
name: 'ApprovalGroupsRuleEditor',
69+
})
70+
71+
const props = defineProps<{
72+
modelValue: EffectivePolicyValue
73+
}>()
74+
75+
const emit = defineEmits<{
76+
'update:modelValue': [value: EffectivePolicyValue]
77+
}>()
78+
79+
const selectedGroupIds = ref<string[]>([])
80+
const availableGroups = ref<GroupRow[]>([])
81+
const loadingGroups = ref(false)
82+
const currentUser = getCurrentUser()
83+
const isInstanceAdmin = currentUser?.isAdmin === true
84+
const config = loadState<{ manageable_policy_group_ids?: string[] }>('libresign', 'config', {})
85+
const manageableGroupIds = new Set(
86+
Array.isArray(config.manageable_policy_group_ids)
87+
? config.manageable_policy_group_ids.filter((groupId): groupId is string => typeof groupId === 'string' && groupId.trim().length > 0)
88+
: [],
89+
)
90+
91+
function clampToManageableScope(groupIds: string[]): string[] {
92+
if (isInstanceAdmin || manageableGroupIds.size === 0) {
93+
return groupIds
94+
}
95+
96+
return groupIds.filter((groupId) => manageableGroupIds.has(groupId))
97+
}
98+
99+
selectedGroupIds.value = clampToManageableScope(resolveApprovalGroups(props.modelValue))
100+
101+
const selectedGroups = computed<Array<GroupRow | string>>(() => {
102+
return selectedGroupIds.value.map((groupId) => {
103+
return availableGroups.value.find((group) => group.id === groupId) ?? groupId
104+
})
105+
})
106+
107+
watch(() => props.modelValue, (nextValue) => {
108+
selectedGroupIds.value = clampToManageableScope(resolveApprovalGroups(nextValue))
109+
})
110+
111+
async function searchGroup(query: string) {
112+
loadingGroups.value = true
113+
try {
114+
const params = {
115+
search: query,
116+
limit: 40,
117+
offset: 0,
118+
}
119+
120+
if (isInstanceAdmin) {
121+
const response = await axios.get<GroupDetailsResponse>(generateOcsUrl('cloud/groups/details'), { params })
122+
availableGroups.value = filterGroupsByManageableScope(
123+
(response.data?.ocs?.data?.groups ?? [])
124+
.map((group) => ({
125+
id: group.id,
126+
displayname: group.displayname || group.id,
127+
})),
128+
)
129+
.sort((left: GroupRow, right: GroupRow) => left.displayname.localeCompare(right.displayname))
130+
return
131+
}
132+
133+
const response = await axios.get<GroupListResponse>(generateOcsUrl('cloud/groups'), { params })
134+
availableGroups.value = filterGroupsByManageableScope(
135+
(response.data?.ocs?.data?.groups ?? [])
136+
.map((groupId) => ({
137+
id: groupId,
138+
displayname: groupId,
139+
})),
140+
)
141+
.sort((left: GroupRow, right: GroupRow) => left.displayname.localeCompare(right.displayname))
142+
} catch (error) {
143+
logger.debug('Could not search groups for approval-group policy editor', { error })
144+
} finally {
145+
loadingGroups.value = false
146+
}
147+
}
148+
149+
function filterGroupsByManageableScope(groups: GroupRow[]): GroupRow[] {
150+
if (isInstanceAdmin || manageableGroupIds.size === 0) {
151+
return groups
152+
}
153+
154+
return groups.filter((group) => manageableGroupIds.has(group.id))
155+
}
156+
157+
function onGroupsChange(value: Array<GroupRow | string>) {
158+
const nextSelectedGroupIds = value
159+
.map((group): string => typeof group === 'string' ? group : group.id)
160+
.map((groupId) => groupId.trim())
161+
.filter((groupId) => groupId.length > 0)
162+
163+
selectedGroupIds.value = clampToManageableScope(nextSelectedGroupIds)
164+
165+
emit('update:modelValue', serializeApprovalGroups(selectedGroupIds.value))
166+
}
167+
168+
onMounted(async () => {
169+
await searchGroup('')
170+
})
171+
</script>
172+
173+
<style scoped lang="scss">
174+
.approval-groups-editor {
175+
display: flex;
176+
flex-direction: column;
177+
gap: 0.75rem;
178+
179+
&__hint {
180+
margin: 0;
181+
font-size: 0.84rem;
182+
color: var(--color-text-maxcontrast);
183+
}
184+
}
185+
</style>

0 commit comments

Comments
 (0)