@conduction/nextcloud-vue 0.1.0-beta.13 → 0.1.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/nextcloud-vue.cjs.js +20518 -1290
- package/dist/nextcloud-vue.cjs.js.map +1 -1
- package/dist/nextcloud-vue.css +470 -185
- package/dist/nextcloud-vue.esm.js +20518 -1290
- package/dist/nextcloud-vue.esm.js.map +1 -1
- package/l10n/en.json +255 -2
- package/l10n/nl.json +247 -2
- package/package.json +2 -1
- package/src/components/CnAdvancedFormDialog/CnDataTab.vue +1 -4
- package/src/components/CnContextMenu/CnContextMenu.vue +1 -1
- package/src/components/CnDataTable/CnDataTable.vue +7 -2
- package/src/components/CnInfoWidget/CnInfoWidget.vue +0 -1
- package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +2 -2
- package/src/components/CnObjectMetadataWidget/CnObjectMetadataWidget.vue +2 -2
- package/src/components/CnRowActions/CnRowActions.vue +1 -1
- package/src/components/CnSchemaFormDialog/CnSchemaConfigurationTab.vue +36 -34
- package/src/components/CnSchemaFormDialog/CnSchemaFormDialog.vue +47 -36
- package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +29 -22
- package/src/components/CnSchemaFormDialog/CnSchemaPropertyActions.vue +170 -163
- package/src/components/CnSchemaFormDialog/CnSchemaSecurityTab.vue +473 -116
- package/src/components/CnStatsBlock/CnStatsBlock.vue +18 -18
- package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +12 -0
- package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +7 -7
- package/src/composables/useContextMenu.js +1 -1
- package/src/css/CnSchemaFormDialog.css +258 -2
- package/src/css/dashboard.css +1 -0
- package/src/css/detail-page.css +5 -5
- package/src/css/index.css +1 -0
- package/src/css/patches.css +20 -0
- package/src/store/plugins/search.js +7 -7
|
@@ -1,187 +1,314 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="cn-schema-form__security-section">
|
|
3
3
|
<CnNoteCard type="info">
|
|
4
|
-
<p><strong>Role-
|
|
5
|
-
<p>Configure which Nextcloud user groups can perform CRUD operations on objects of this schema
|
|
4
|
+
<p><strong>{{ t('nextcloud-vue', 'Role-based access control (RBAC)') }}</strong></p>
|
|
5
|
+
<p>{{ t('nextcloud-vue', 'Configure which Nextcloud user groups can perform CRUD operations on objects of this schema.') }}</p>
|
|
6
6
|
<ul>
|
|
7
|
-
<li>If no groups are specified for an operation, all users can perform it</li>
|
|
8
|
-
<li>The 'admin' group always has full access (cannot be changed)</li>
|
|
9
|
-
<li>The object owner always has full access</li>
|
|
10
|
-
<li>'public' represents unauthenticated access</li>
|
|
7
|
+
<li>{{ t('nextcloud-vue', 'If no groups are specified for an operation, all users can perform it') }}</li>
|
|
8
|
+
<li>{{ t('nextcloud-vue', "The 'admin' group always has full access (cannot be changed)") }}</li>
|
|
9
|
+
<li>{{ t('nextcloud-vue', 'The object owner always has full access') }}</li>
|
|
10
|
+
<li>{{ t('nextcloud-vue', "'public' represents unauthenticated access") }}</li>
|
|
11
11
|
</ul>
|
|
12
12
|
</CnNoteCard>
|
|
13
13
|
|
|
14
14
|
<div v-if="loadingGroups" class="cn-schema-form__loading-groups">
|
|
15
15
|
<NcLoadingIcon :size="20" />
|
|
16
|
-
<span>Loading user groups
|
|
16
|
+
<span>{{ t('nextcloud-vue', 'Loading user groups...') }}</span>
|
|
17
17
|
</div>
|
|
18
18
|
|
|
19
19
|
<div v-else class="cn-schema-form__rbac-table-container">
|
|
20
|
-
<h3>Group
|
|
20
|
+
<h3>{{ t('nextcloud-vue', 'Group permissions') }}</h3>
|
|
21
21
|
<table class="cn-schema-form__rbac-table">
|
|
22
22
|
<thead>
|
|
23
23
|
<tr>
|
|
24
|
-
<th>Group</th>
|
|
25
|
-
<th>Create</th>
|
|
26
|
-
<th>Read</th>
|
|
27
|
-
<th>Update</th>
|
|
28
|
-
<th>Delete</th>
|
|
24
|
+
<th>{{ t('nextcloud-vue', 'Group') }}</th>
|
|
25
|
+
<th>{{ t('nextcloud-vue', 'Create') }}</th>
|
|
26
|
+
<th>{{ t('nextcloud-vue', 'Read') }}</th>
|
|
27
|
+
<th>{{ t('nextcloud-vue', 'Update') }}</th>
|
|
28
|
+
<th>{{ t('nextcloud-vue', 'Delete') }}</th>
|
|
29
29
|
</tr>
|
|
30
30
|
</thead>
|
|
31
31
|
<tbody>
|
|
32
|
-
<!-- Public group at top -->
|
|
33
32
|
<tr class="cn-schema-form__public-row">
|
|
34
33
|
<td class="cn-schema-form__group-name">
|
|
35
34
|
<span class="cn-schema-form__group-badge cn-schema-form__public">public</span>
|
|
36
|
-
<small>Unauthenticated users</small>
|
|
37
|
-
</td>
|
|
38
|
-
<td>
|
|
39
|
-
<NcCheckboxRadioSwitch
|
|
40
|
-
:checked="hasGroupPermission('public', 'create')"
|
|
41
|
-
@update:checked="updateGroupPermission('public', 'create', $event)" />
|
|
42
|
-
</td>
|
|
43
|
-
<td>
|
|
44
|
-
<NcCheckboxRadioSwitch
|
|
45
|
-
:checked="hasGroupPermission('public', 'read')"
|
|
46
|
-
@update:checked="updateGroupPermission('public', 'read', $event)" />
|
|
47
|
-
</td>
|
|
48
|
-
<td>
|
|
49
|
-
<NcCheckboxRadioSwitch
|
|
50
|
-
:checked="hasGroupPermission('public', 'update')"
|
|
51
|
-
@update:checked="updateGroupPermission('public', 'update', $event)" />
|
|
52
|
-
</td>
|
|
53
|
-
<td>
|
|
54
|
-
<NcCheckboxRadioSwitch
|
|
55
|
-
:checked="hasGroupPermission('public', 'delete')"
|
|
56
|
-
@update:checked="updateGroupPermission('public', 'delete', $event)" />
|
|
35
|
+
<small>{{ t('nextcloud-vue', 'Unauthenticated users') }}</small>
|
|
57
36
|
</td>
|
|
37
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission('public', 'create')" @update:checked="updateGroupPermission('public', 'create', $event)" /></td>
|
|
38
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission('public', 'read')" @update:checked="updateGroupPermission('public', 'read', $event)" /></td>
|
|
39
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission('public', 'update')" @update:checked="updateGroupPermission('public', 'update', $event)" /></td>
|
|
40
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission('public', 'delete')" @update:checked="updateGroupPermission('public', 'delete', $event)" /></td>
|
|
58
41
|
</tr>
|
|
59
|
-
|
|
60
|
-
<!-- Authenticated users group -->
|
|
61
42
|
<tr class="cn-schema-form__user-row">
|
|
62
43
|
<td class="cn-schema-form__group-name">
|
|
63
44
|
<span class="cn-schema-form__group-badge cn-schema-form__user">authenticated</span>
|
|
64
|
-
<small>Authenticated users</small>
|
|
65
|
-
</td>
|
|
66
|
-
<td>
|
|
67
|
-
<NcCheckboxRadioSwitch
|
|
68
|
-
:checked="hasGroupPermission('authenticated', 'create')"
|
|
69
|
-
@update:checked="updateGroupPermission('authenticated', 'create', $event)" />
|
|
70
|
-
</td>
|
|
71
|
-
<td>
|
|
72
|
-
<NcCheckboxRadioSwitch
|
|
73
|
-
:checked="hasGroupPermission('authenticated', 'read')"
|
|
74
|
-
@update:checked="updateGroupPermission('authenticated', 'read', $event)" />
|
|
75
|
-
</td>
|
|
76
|
-
<td>
|
|
77
|
-
<NcCheckboxRadioSwitch
|
|
78
|
-
:checked="hasGroupPermission('authenticated', 'update')"
|
|
79
|
-
@update:checked="updateGroupPermission('authenticated', 'update', $event)" />
|
|
80
|
-
</td>
|
|
81
|
-
<td>
|
|
82
|
-
<NcCheckboxRadioSwitch
|
|
83
|
-
:checked="hasGroupPermission('authenticated', 'delete')"
|
|
84
|
-
@update:checked="updateGroupPermission('authenticated', 'delete', $event)" />
|
|
45
|
+
<small>{{ t('nextcloud-vue', 'Authenticated users') }}</small>
|
|
85
46
|
</td>
|
|
47
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission('authenticated', 'create')" @update:checked="updateGroupPermission('authenticated', 'create', $event)" /></td>
|
|
48
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission('authenticated', 'read')" @update:checked="updateGroupPermission('authenticated', 'read', $event)" /></td>
|
|
49
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission('authenticated', 'update')" @update:checked="updateGroupPermission('authenticated', 'update', $event)" /></td>
|
|
50
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission('authenticated', 'delete')" @update:checked="updateGroupPermission('authenticated', 'delete', $event)" /></td>
|
|
86
51
|
</tr>
|
|
87
|
-
|
|
88
|
-
<!-- Regular user groups -->
|
|
89
52
|
<tr v-for="group in sortedUserGroups" :key="group.id">
|
|
90
53
|
<td class="cn-schema-form__group-name">
|
|
91
54
|
<span class="cn-schema-form__group-badge">{{ group.displayname || group.id }}</span>
|
|
92
55
|
<small v-if="group.displayname && group.displayname !== group.id">{{ group.id }}</small>
|
|
93
56
|
</td>
|
|
94
|
-
<td>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
</td>
|
|
99
|
-
<td>
|
|
100
|
-
<NcCheckboxRadioSwitch
|
|
101
|
-
:checked="hasGroupPermission(group.id, 'read')"
|
|
102
|
-
@update:checked="updateGroupPermission(group.id, 'read', $event)" />
|
|
103
|
-
</td>
|
|
104
|
-
<td>
|
|
105
|
-
<NcCheckboxRadioSwitch
|
|
106
|
-
:checked="hasGroupPermission(group.id, 'update')"
|
|
107
|
-
@update:checked="updateGroupPermission(group.id, 'update', $event)" />
|
|
108
|
-
</td>
|
|
109
|
-
<td>
|
|
110
|
-
<NcCheckboxRadioSwitch
|
|
111
|
-
:checked="hasGroupPermission(group.id, 'delete')"
|
|
112
|
-
@update:checked="updateGroupPermission(group.id, 'delete', $event)" />
|
|
113
|
-
</td>
|
|
57
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission(group.id, 'create')" @update:checked="updateGroupPermission(group.id, 'create', $event)" /></td>
|
|
58
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission(group.id, 'read')" @update:checked="updateGroupPermission(group.id, 'read', $event)" /></td>
|
|
59
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission(group.id, 'update')" @update:checked="updateGroupPermission(group.id, 'update', $event)" /></td>
|
|
60
|
+
<td><NcCheckboxRadioSwitch :checked="hasGroupPermission(group.id, 'delete')" @update:checked="updateGroupPermission(group.id, 'delete', $event)" /></td>
|
|
114
61
|
</tr>
|
|
115
|
-
|
|
116
|
-
<!-- Admin group at bottom (disabled) -->
|
|
117
62
|
<tr class="cn-schema-form__admin-row">
|
|
118
63
|
<td class="cn-schema-form__group-name">
|
|
119
64
|
<span class="cn-schema-form__group-badge cn-schema-form__admin">admin</span>
|
|
120
|
-
<small>Always has full access</small>
|
|
121
|
-
</td>
|
|
122
|
-
<td>
|
|
123
|
-
<NcCheckboxRadioSwitch
|
|
124
|
-
:checked="true"
|
|
125
|
-
:disabled="true" />
|
|
126
|
-
</td>
|
|
127
|
-
<td>
|
|
128
|
-
<NcCheckboxRadioSwitch
|
|
129
|
-
:checked="true"
|
|
130
|
-
:disabled="true" />
|
|
131
|
-
</td>
|
|
132
|
-
<td>
|
|
133
|
-
<NcCheckboxRadioSwitch
|
|
134
|
-
:checked="true"
|
|
135
|
-
:disabled="true" />
|
|
136
|
-
</td>
|
|
137
|
-
<td>
|
|
138
|
-
<NcCheckboxRadioSwitch
|
|
139
|
-
:checked="true"
|
|
140
|
-
:disabled="true" />
|
|
65
|
+
<small>{{ t('nextcloud-vue', 'Always has full access') }}</small>
|
|
141
66
|
</td>
|
|
67
|
+
<td><NcCheckboxRadioSwitch :checked="true" :disabled="true" /></td>
|
|
68
|
+
<td><NcCheckboxRadioSwitch :checked="true" :disabled="true" /></td>
|
|
69
|
+
<td><NcCheckboxRadioSwitch :checked="true" :disabled="true" /></td>
|
|
70
|
+
<td><NcCheckboxRadioSwitch :checked="true" :disabled="true" /></td>
|
|
142
71
|
</tr>
|
|
143
72
|
</tbody>
|
|
144
73
|
</table>
|
|
145
74
|
|
|
146
75
|
<div class="cn-schema-form__rbac-summary">
|
|
147
76
|
<CnNoteCard v-if="!hasAnyPermissions" type="success">
|
|
148
|
-
<p><strong>Open
|
|
77
|
+
<p><strong>{{ t('nextcloud-vue', 'Open access:') }}</strong> {{ t('nextcloud-vue', 'No specific permissions set — all users can perform all operations.') }}</p>
|
|
149
78
|
</CnNoteCard>
|
|
150
79
|
<CnNoteCard v-else-if="isRestrictiveSchema" type="warning">
|
|
151
|
-
<p><strong>Restrictive
|
|
80
|
+
<p><strong>{{ t('nextcloud-vue', 'Restrictive schema:') }}</strong> {{ t('nextcloud-vue', 'Access is limited to specified groups only.') }}</p>
|
|
152
81
|
</CnNoteCard>
|
|
153
82
|
</div>
|
|
154
83
|
</div>
|
|
84
|
+
|
|
85
|
+
<!-- Advanced: Conditional Access Rules (accordion) -->
|
|
86
|
+
<div class="cn-schema-form__conditional-section">
|
|
87
|
+
<!--
|
|
88
|
+
type="button" prevents browser from treating this as a form submit button,
|
|
89
|
+
which would reset scroll position inside NcDialog's internal <form>.
|
|
90
|
+
-->
|
|
91
|
+
<button type="button"
|
|
92
|
+
class="cn-schema-form__cond-accordion-header"
|
|
93
|
+
@click="showAdvanced = !showAdvanced">
|
|
94
|
+
<ChevronDown v-if="showAdvanced" :size="20" class="cn-schema-form__cond-chevron" />
|
|
95
|
+
<ChevronRight v-else :size="20" class="cn-schema-form__cond-chevron" />
|
|
96
|
+
<span>{{ t('nextcloud-vue', 'Advanced: Conditional access rules') }}</span>
|
|
97
|
+
<span v-if="totalConditionalRules > 0" class="cn-schema-form__cond-count-badge">
|
|
98
|
+
{{ totalConditionalRules }}
|
|
99
|
+
</span>
|
|
100
|
+
</button>
|
|
101
|
+
|
|
102
|
+
<div v-show="showAdvanced" class="cn-schema-form__cond-accordion-body">
|
|
103
|
+
<CnNoteCard type="info">
|
|
104
|
+
<p>{{ t('nextcloud-vue', "Grant access based on object property values evaluated at runtime. Multiple rules per action are OR'd — any matching rule grants access.") }}</p>
|
|
105
|
+
<p>
|
|
106
|
+
<strong>{{ t('nextcloud-vue', 'Variables:') }}</strong>
|
|
107
|
+
<code>$now</code> {{ t('nextcloud-vue', '(current date/time)') }}
|
|
108
|
+
<code>$userId</code> {{ t('nextcloud-vue', '(current user ID)') }}
|
|
109
|
+
<code>$organisation</code> {{ t('nextcloud-vue', '(current organisation)') }}
|
|
110
|
+
</p>
|
|
111
|
+
</CnNoteCard>
|
|
112
|
+
|
|
113
|
+
<div v-for="action in actions" :key="action" class="cn-schema-form__cond-action">
|
|
114
|
+
<div class="cn-schema-form__cond-action-header">
|
|
115
|
+
<strong class="cn-schema-form__cond-action-name">{{ capitalize(action) }}</strong>
|
|
116
|
+
<NcButton size="small" @click="addConditionalRule(action)">
|
|
117
|
+
<template #icon>
|
|
118
|
+
<Plus :size="16" />
|
|
119
|
+
</template>
|
|
120
|
+
{{ t('nextcloud-vue', 'Add rule') }}
|
|
121
|
+
</NcButton>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div v-if="getConditionalRules(action).length === 0" class="cn-schema-form__cond-empty">
|
|
125
|
+
{{ t('nextcloud-vue', 'No conditional rules for {action}', { action }) }}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div v-for="({ rule, originalIndex }, ruleIdx) in getConditionalRules(action)"
|
|
129
|
+
:key="originalIndex"
|
|
130
|
+
class="cn-schema-form__cond-rule-card"
|
|
131
|
+
:class="{
|
|
132
|
+
'cn-schema-form__cond-rule-card--public': rule.group === 'public',
|
|
133
|
+
'cn-schema-form__cond-rule-card--authenticated': rule.group === 'authenticated',
|
|
134
|
+
'cn-schema-form__cond-rule-card--admin': rule.group === 'admin',
|
|
135
|
+
}">
|
|
136
|
+
<!-- Rule header row — styled like a table row with group + remove -->
|
|
137
|
+
<div class="cn-schema-form__cond-rule-header">
|
|
138
|
+
<span class="cn-schema-form__group-badge">
|
|
139
|
+
{{ rule.group }}
|
|
140
|
+
</span>
|
|
141
|
+
<div class="cn-schema-form__cond-rule-group-select">
|
|
142
|
+
<NcSelect
|
|
143
|
+
:value="getGroupOption(rule.group)"
|
|
144
|
+
:options="allGroupOptions"
|
|
145
|
+
:clearable="false"
|
|
146
|
+
:aria-label-combobox="t('nextcloud-vue', 'Group')"
|
|
147
|
+
@input="setRuleGroup(action, originalIndex, $event)" />
|
|
148
|
+
</div>
|
|
149
|
+
<NcButton type="error"
|
|
150
|
+
@click="removeConditionalRule(action, originalIndex)">
|
|
151
|
+
<template #icon>
|
|
152
|
+
<TrashCanOutline :size="16" />
|
|
153
|
+
</template>
|
|
154
|
+
{{ t('nextcloud-vue', 'Remove rule') }}
|
|
155
|
+
</NcButton>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- Conditions list -->
|
|
159
|
+
<div class="cn-schema-form__cond-match-list">
|
|
160
|
+
<p v-if="!rule.match || Object.keys(rule.match).length === 0"
|
|
161
|
+
class="cn-schema-form__cond-match-empty">
|
|
162
|
+
{{ t('nextcloud-vue', 'No conditions yet — add at least one condition') }}
|
|
163
|
+
</p>
|
|
164
|
+
<table v-else class="cn-schema-form__cond-match-table">
|
|
165
|
+
<thead>
|
|
166
|
+
<tr>
|
|
167
|
+
<th>{{ t('nextcloud-vue', 'Property') }}</th>
|
|
168
|
+
<th>{{ t('nextcloud-vue', 'Operator') }}</th>
|
|
169
|
+
<th>{{ t('nextcloud-vue', 'Value') }}</th>
|
|
170
|
+
<th />
|
|
171
|
+
</tr>
|
|
172
|
+
</thead>
|
|
173
|
+
<tbody>
|
|
174
|
+
<tr v-for="(condObj, propKey) in (rule.match || {})"
|
|
175
|
+
:key="propKey"
|
|
176
|
+
class="cn-schema-form__cond-match-row">
|
|
177
|
+
<td>{{ propKey }}</td>
|
|
178
|
+
<td :title="Object.keys(condObj)[0]">
|
|
179
|
+
{{ getOperatorLabel(Object.keys(condObj)[0]) }}
|
|
180
|
+
</td>
|
|
181
|
+
<td>{{ formatConditionValue(Object.values(condObj)[0]) }}</td>
|
|
182
|
+
<td class="cn-schema-form__cond-match-actions">
|
|
183
|
+
<NcButton type="error"
|
|
184
|
+
size="small"
|
|
185
|
+
:aria-label="t('nextcloud-vue', 'Remove condition')"
|
|
186
|
+
@click="removeCondition(action, originalIndex, propKey)">
|
|
187
|
+
<template #icon>
|
|
188
|
+
<Close :size="14" />
|
|
189
|
+
</template>
|
|
190
|
+
</NcButton>
|
|
191
|
+
</td>
|
|
192
|
+
</tr>
|
|
193
|
+
</tbody>
|
|
194
|
+
</table>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<!-- Inline add-condition form -->
|
|
198
|
+
<div v-if="isAddingConditionFor(action, ruleIdx)"
|
|
199
|
+
:ref="`addForm-${action}-${ruleIdx}`"
|
|
200
|
+
class="cn-schema-form__cond-add-form">
|
|
201
|
+
<div class="cn-schema-form__cond-add-row">
|
|
202
|
+
<div class="cn-schema-form__cond-add-field">
|
|
203
|
+
<NcSelect
|
|
204
|
+
v-model="newCondition.propertyOption"
|
|
205
|
+
:options="availablePropertyOptions(action, ruleIdx)"
|
|
206
|
+
:clearable="false"
|
|
207
|
+
:input-label="t('nextcloud-vue', 'Property')"
|
|
208
|
+
:placeholder="t('nextcloud-vue', 'Select property')" />
|
|
209
|
+
</div>
|
|
210
|
+
<div class="cn-schema-form__cond-add-field">
|
|
211
|
+
<NcSelect
|
|
212
|
+
v-model="newCondition.operatorOption"
|
|
213
|
+
:options="operatorOptions"
|
|
214
|
+
:clearable="false"
|
|
215
|
+
:input-label="t('nextcloud-vue', 'Operator')" />
|
|
216
|
+
</div>
|
|
217
|
+
<div class="cn-schema-form__cond-add-field">
|
|
218
|
+
<NcSelect
|
|
219
|
+
v-if="newCondition.operatorOption && newCondition.operatorOption.id === '$exists'"
|
|
220
|
+
v-model="newCondition.existsOption"
|
|
221
|
+
:options="existsOptions"
|
|
222
|
+
:clearable="false"
|
|
223
|
+
:input-label="t('nextcloud-vue', 'Value')" />
|
|
224
|
+
<NcSelect
|
|
225
|
+
v-else
|
|
226
|
+
v-model="newCondition.valueOption"
|
|
227
|
+
:options="specialValueOptions"
|
|
228
|
+
:clearable="true"
|
|
229
|
+
:input-label="t('nextcloud-vue', 'Value')"
|
|
230
|
+
:placeholder="t('nextcloud-vue', 'Select or type…')"
|
|
231
|
+
@input="onValueOptionChange" />
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
<!-- Custom value appears below the three selects, never displaces them -->
|
|
235
|
+
<div v-if="newCondition.customValue !== null" class="cn-schema-form__cond-custom-row">
|
|
236
|
+
<label class="cn-schema-form__cond-add-label">{{ t('nextcloud-vue', 'Custom value') }}</label>
|
|
237
|
+
<input v-model="newCondition.customValue"
|
|
238
|
+
class="cn-schema-form__cond-custom-input"
|
|
239
|
+
:placeholder="t('nextcloud-vue', 'Enter a custom value')">
|
|
240
|
+
</div>
|
|
241
|
+
<div class="cn-schema-form__cond-add-actions">
|
|
242
|
+
<NcButton @click="confirmAddCondition(action, originalIndex)">
|
|
243
|
+
{{ t('nextcloud-vue', 'Add') }}
|
|
244
|
+
</NcButton>
|
|
245
|
+
<NcButton type="tertiary" @click="cancelAddCondition()">
|
|
246
|
+
{{ t('nextcloud-vue', 'Cancel') }}
|
|
247
|
+
</NcButton>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<NcButton v-else
|
|
252
|
+
size="small"
|
|
253
|
+
@click="startAddCondition(action, ruleIdx)">
|
|
254
|
+
<template #icon>
|
|
255
|
+
<Plus :size="14" />
|
|
256
|
+
</template>
|
|
257
|
+
{{ t('nextcloud-vue', 'Add condition') }}
|
|
258
|
+
</NcButton>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
155
263
|
</div>
|
|
156
264
|
</template>
|
|
157
265
|
|
|
158
266
|
<script>
|
|
267
|
+
import _ from 'lodash'
|
|
159
268
|
import {
|
|
160
|
-
|
|
269
|
+
NcButton,
|
|
161
270
|
NcCheckboxRadioSwitch,
|
|
162
271
|
NcLoadingIcon,
|
|
272
|
+
NcSelect,
|
|
163
273
|
} from '@nextcloud/vue'
|
|
164
274
|
import CnNoteCard from '../CnNoteCard/CnNoteCard.vue'
|
|
165
275
|
|
|
276
|
+
import Plus from 'vue-material-design-icons/Plus.vue'
|
|
277
|
+
import Close from 'vue-material-design-icons/Close.vue'
|
|
278
|
+
import TrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
|
|
279
|
+
import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
|
|
280
|
+
import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
|
|
281
|
+
|
|
166
282
|
/**
|
|
167
|
-
* CnSchemaSecurityTab — RBAC permissions table tab for CnSchemaFormDialog.
|
|
283
|
+
* CnSchemaSecurityTab — RBAC permissions table + conditional access rules tab for CnSchemaFormDialog.
|
|
168
284
|
*
|
|
169
285
|
* Renders the schema-level authorization configuration. Mutates
|
|
170
286
|
* schemaItem.authorization directly.
|
|
287
|
+
*
|
|
288
|
+
* authorization[action] is a mixed array:
|
|
289
|
+
* - strings like "public", "authenticated" — managed by the RBAC checkboxes
|
|
290
|
+
* - objects like { group, match } — managed by the conditional rules accordion
|
|
171
291
|
*/
|
|
172
292
|
export default {
|
|
173
293
|
name: 'CnSchemaSecurityTab',
|
|
174
294
|
components: {
|
|
175
295
|
CnNoteCard,
|
|
296
|
+
NcButton,
|
|
176
297
|
NcCheckboxRadioSwitch,
|
|
177
298
|
NcLoadingIcon,
|
|
299
|
+
NcSelect,
|
|
300
|
+
Plus,
|
|
301
|
+
Close,
|
|
302
|
+
TrashCanOutline,
|
|
303
|
+
ChevronDown,
|
|
304
|
+
ChevronRight,
|
|
178
305
|
},
|
|
179
306
|
props: {
|
|
180
307
|
/** The full schema item — mutates authorization directly */
|
|
181
308
|
schemaItem: { type: Object, required: true },
|
|
182
309
|
/** Full user groups array */
|
|
183
310
|
userGroups: { type: Array, default: () => [] },
|
|
184
|
-
/** Filtered/sorted user groups (excludes admin/public) */
|
|
311
|
+
/** Filtered/sorted user groups (excludes admin/public/authenticated) */
|
|
185
312
|
sortedUserGroups: { type: Array, default: () => [] },
|
|
186
313
|
/** Whether groups are loading */
|
|
187
314
|
loadingGroups: { type: Boolean, default: false },
|
|
@@ -190,18 +317,117 @@ export default {
|
|
|
190
317
|
/** Whether schema has restrictive permissions */
|
|
191
318
|
isRestrictiveSchema: { type: Boolean, default: false },
|
|
192
319
|
},
|
|
320
|
+
data() {
|
|
321
|
+
return {
|
|
322
|
+
actions: ['create', 'read', 'update', 'delete'],
|
|
323
|
+
showAdvanced: false,
|
|
324
|
+
addingCondition: { action: null, ruleIdx: null },
|
|
325
|
+
newCondition: {
|
|
326
|
+
propertyOption: null,
|
|
327
|
+
operatorOption: null,
|
|
328
|
+
valueOption: null,
|
|
329
|
+
customValue: null,
|
|
330
|
+
existsOption: null,
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
},
|
|
193
334
|
computed: {
|
|
194
335
|
/** Local alias to avoid vue/no-mutating-props on template bindings */
|
|
195
336
|
schema() {
|
|
196
337
|
return this.schemaItem
|
|
197
338
|
},
|
|
339
|
+
|
|
340
|
+
operatorOptions() {
|
|
341
|
+
return [
|
|
342
|
+
{ id: '$eq', label: t('nextcloud-vue', '= Equal to') },
|
|
343
|
+
{ id: '$ne', label: t('nextcloud-vue', '≠ Not equal to') },
|
|
344
|
+
{ id: '$gt', label: t('nextcloud-vue', '> Greater than') },
|
|
345
|
+
{ id: '$gte', label: t('nextcloud-vue', '≥ Greater than or equal') },
|
|
346
|
+
{ id: '$lt', label: t('nextcloud-vue', '< Less than') },
|
|
347
|
+
{ id: '$lte', label: t('nextcloud-vue', '≤ Less than or equal') },
|
|
348
|
+
{ id: '$in', label: t('nextcloud-vue', '∈ In list') },
|
|
349
|
+
{ id: '$nin', label: t('nextcloud-vue', '∉ Not in list') },
|
|
350
|
+
{ id: '$exists', label: t('nextcloud-vue', '∃ Exists') },
|
|
351
|
+
]
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
propertyOptions() {
|
|
355
|
+
const schemaProps = Object.keys(this.schemaItem.properties || {}).map(key => ({
|
|
356
|
+
id: key,
|
|
357
|
+
label: key,
|
|
358
|
+
}))
|
|
359
|
+
const systemProps = [
|
|
360
|
+
{ id: '_organisation', label: t('nextcloud-vue', '_organisation (system)') },
|
|
361
|
+
{ id: '_owner', label: t('nextcloud-vue', '_owner (system)') },
|
|
362
|
+
{ id: '_created', label: t('nextcloud-vue', '_created (system)') },
|
|
363
|
+
{ id: '_updated', label: t('nextcloud-vue', '_updated (system)') },
|
|
364
|
+
]
|
|
365
|
+
return [...schemaProps, ...systemProps]
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
specialValueOptions() {
|
|
369
|
+
return [
|
|
370
|
+
{ id: '$now', label: t('nextcloud-vue', '$now — current date/time') },
|
|
371
|
+
{ id: '$userId', label: t('nextcloud-vue', '$userId — current user ID') },
|
|
372
|
+
{ id: '$organisation', label: t('nextcloud-vue', '$organisation — current organisation') },
|
|
373
|
+
{ id: '__custom__', label: t('nextcloud-vue', 'Custom value…') },
|
|
374
|
+
]
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
existsOptions() {
|
|
378
|
+
return [
|
|
379
|
+
{ id: 'true', label: t('nextcloud-vue', 'true — field must exist') },
|
|
380
|
+
{ id: 'false', label: t('nextcloud-vue', 'false — field must not exist') },
|
|
381
|
+
]
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
allGroupOptions() {
|
|
385
|
+
return [
|
|
386
|
+
{ id: 'public', label: 'public' },
|
|
387
|
+
{ id: 'authenticated', label: 'authenticated' },
|
|
388
|
+
...this.sortedUserGroups.map(g => ({ id: g.id, label: g.displayname || g.id })),
|
|
389
|
+
]
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
totalConditionalRules() {
|
|
393
|
+
return this.actions.reduce((total, action) => {
|
|
394
|
+
return total + this.getConditionalRules(action).length
|
|
395
|
+
}, 0)
|
|
396
|
+
},
|
|
198
397
|
},
|
|
199
398
|
methods: {
|
|
399
|
+
capitalize: _.capitalize,
|
|
400
|
+
|
|
401
|
+
availablePropertyOptions(action, ruleIdx) {
|
|
402
|
+
const rules = this.getConditionalRules(action)
|
|
403
|
+
const currentRule = rules[ruleIdx]
|
|
404
|
+
if (!currentRule || !currentRule.rule.match) return this.propertyOptions
|
|
405
|
+
const used = Object.keys(currentRule.rule.match)
|
|
406
|
+
return this.propertyOptions.filter(opt => !used.includes(opt.id))
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
// ─── Operator / value helpers ─────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
getOperatorLabel(opId) {
|
|
412
|
+
const op = this.operatorOptions.find(o => o.id === opId)
|
|
413
|
+
return op ? op.label : opId
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
formatConditionValue(val) {
|
|
417
|
+
if (Array.isArray(val)) return val.join(', ')
|
|
418
|
+
return String(val)
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
getGroupOption(groupId) {
|
|
422
|
+
return this.allGroupOptions.find(opt => opt.id === groupId)
|
|
423
|
+
|| { id: groupId, label: groupId }
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
// ─── Simple RBAC (string entries) ────────────────────────────────
|
|
427
|
+
|
|
200
428
|
hasGroupPermission(groupId, action) {
|
|
201
429
|
const auth = this.schema.authorization || {}
|
|
202
|
-
if (!auth[action] || !Array.isArray(auth[action]))
|
|
203
|
-
return false
|
|
204
|
-
}
|
|
430
|
+
if (!auth[action] || !Array.isArray(auth[action])) return false
|
|
205
431
|
return auth[action].includes(groupId)
|
|
206
432
|
},
|
|
207
433
|
|
|
@@ -209,28 +435,159 @@ export default {
|
|
|
209
435
|
if (!this.schema.authorization) {
|
|
210
436
|
this.$set(this.schema, 'authorization', {})
|
|
211
437
|
}
|
|
212
|
-
|
|
213
438
|
if (!this.schema.authorization[action]) {
|
|
214
439
|
this.$set(this.schema.authorization, action, [])
|
|
215
440
|
}
|
|
216
|
-
|
|
217
441
|
const currentPermissions = this.schema.authorization[action]
|
|
218
442
|
const groupIndex = currentPermissions.indexOf(groupId)
|
|
219
|
-
|
|
220
443
|
if (hasPermission && groupIndex === -1) {
|
|
221
444
|
currentPermissions.push(groupId)
|
|
222
445
|
} else if (!hasPermission && groupIndex !== -1) {
|
|
223
446
|
currentPermissions.splice(groupIndex, 1)
|
|
224
447
|
}
|
|
225
|
-
|
|
226
448
|
if (currentPermissions.length === 0) {
|
|
227
449
|
this.$delete(this.schema.authorization, action)
|
|
228
450
|
}
|
|
229
|
-
|
|
230
451
|
if (Object.keys(this.schema.authorization).length === 0) {
|
|
231
452
|
this.$set(this.schema, 'authorization', {})
|
|
232
453
|
}
|
|
233
454
|
},
|
|
455
|
+
|
|
456
|
+
// ─── Conditional rules (object entries) ──────────────────────────
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Returns conditional (object-type) entries for an action with their original array indices.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} action - The CRUD action name (create, read, update, delete).
|
|
462
|
+
* @return {{ rule: object, originalIndex: number }[]}
|
|
463
|
+
*/
|
|
464
|
+
getConditionalRules(action) {
|
|
465
|
+
const auth = this.schema.authorization || {}
|
|
466
|
+
if (!auth[action] || !Array.isArray(auth[action])) return []
|
|
467
|
+
const result = []
|
|
468
|
+
auth[action].forEach((entry, index) => {
|
|
469
|
+
if (entry && typeof entry === 'object') {
|
|
470
|
+
result.push({ rule: entry, originalIndex: index })
|
|
471
|
+
}
|
|
472
|
+
})
|
|
473
|
+
return result
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
addConditionalRule(action) {
|
|
477
|
+
if (!this.schema.authorization) {
|
|
478
|
+
this.$set(this.schema, 'authorization', {})
|
|
479
|
+
}
|
|
480
|
+
if (!this.schema.authorization[action]) {
|
|
481
|
+
this.$set(this.schema.authorization, action, [])
|
|
482
|
+
}
|
|
483
|
+
this.schema.authorization[action].push({ group: 'public', match: {} })
|
|
484
|
+
// Focus the new rule card's first interactive element so NcDialog's focus-trap
|
|
485
|
+
// does not reset focus to the dialog top when new DOM appears.
|
|
486
|
+
this.$nextTick(() => {
|
|
487
|
+
const cards = this.$el.querySelectorAll('.cn-schema-form__cond-rule-card')
|
|
488
|
+
const lastCard = cards[cards.length - 1]
|
|
489
|
+
if (lastCard) {
|
|
490
|
+
const firstFocusable = lastCard.querySelector('input, button, [tabindex]')
|
|
491
|
+
if (firstFocusable) firstFocusable.focus({ preventScroll: true })
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
removeConditionalRule(action, originalIndex) {
|
|
497
|
+
const auth = this.schema.authorization
|
|
498
|
+
if (!auth || !auth[action]) return
|
|
499
|
+
auth[action].splice(originalIndex, 1)
|
|
500
|
+
if (auth[action].length === 0) {
|
|
501
|
+
this.$delete(this.schema.authorization, action)
|
|
502
|
+
}
|
|
503
|
+
this.cancelAddCondition()
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
setRuleGroup(action, originalIndex, option) {
|
|
507
|
+
if (option && option.id) {
|
|
508
|
+
this.$set(this.schema.authorization[action][originalIndex], 'group', option.id)
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
removeCondition(action, originalIndex, propKey) {
|
|
513
|
+
const match = this.schema.authorization[action][originalIndex].match
|
|
514
|
+
if (match) {
|
|
515
|
+
this.$delete(match, propKey)
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
// ─── Add-condition form state ─────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
isAddingConditionFor(action, ruleIdx) {
|
|
522
|
+
return this.addingCondition.action === action && this.addingCondition.ruleIdx === ruleIdx
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
startAddCondition(action, ruleIdx) {
|
|
526
|
+
this.addingCondition = { action, ruleIdx }
|
|
527
|
+
this.newCondition = {
|
|
528
|
+
propertyOption: null,
|
|
529
|
+
operatorOption: this.operatorOptions.find(o => o.id === '$lte'),
|
|
530
|
+
valueOption: null,
|
|
531
|
+
customValue: null,
|
|
532
|
+
existsOption: this.existsOptions[0],
|
|
533
|
+
}
|
|
534
|
+
// Focus the first input inside the new form so NcDialog's focus-trap does not
|
|
535
|
+
// reset focus to the dialog top when the "Add condition" button unmounts.
|
|
536
|
+
this.$nextTick(() => {
|
|
537
|
+
const refKey = `addForm-${action}-${ruleIdx}`
|
|
538
|
+
const formEl = this.$refs[refKey]
|
|
539
|
+
const form = Array.isArray(formEl) ? formEl[0] : formEl
|
|
540
|
+
if (form) {
|
|
541
|
+
const firstInput = (form.$el || form).querySelector('input, [tabindex="0"]')
|
|
542
|
+
if (firstInput) firstInput.focus({ preventScroll: true })
|
|
543
|
+
}
|
|
544
|
+
})
|
|
545
|
+
},
|
|
546
|
+
|
|
547
|
+
cancelAddCondition() {
|
|
548
|
+
this.addingCondition = { action: null, ruleIdx: null }
|
|
549
|
+
this.newCondition = {
|
|
550
|
+
propertyOption: null,
|
|
551
|
+
operatorOption: null,
|
|
552
|
+
valueOption: null,
|
|
553
|
+
customValue: null,
|
|
554
|
+
existsOption: null,
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
onValueOptionChange(option) {
|
|
559
|
+
if (option && option.id === '__custom__') {
|
|
560
|
+
this.newCondition.customValue = ''
|
|
561
|
+
} else {
|
|
562
|
+
this.newCondition.customValue = null
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
confirmAddCondition(action, originalIndex) {
|
|
567
|
+
const property = this.newCondition.propertyOption && this.newCondition.propertyOption.id
|
|
568
|
+
const operator = this.newCondition.operatorOption && this.newCondition.operatorOption.id
|
|
569
|
+
if (!property || !operator) return
|
|
570
|
+
|
|
571
|
+
let conditionValue
|
|
572
|
+
if (operator === '$exists') {
|
|
573
|
+
const existsId = this.newCondition.existsOption && this.newCondition.existsOption.id
|
|
574
|
+
conditionValue = existsId !== 'false'
|
|
575
|
+
} else if (this.newCondition.customValue !== null) {
|
|
576
|
+
conditionValue = this.newCondition.customValue
|
|
577
|
+
} else {
|
|
578
|
+
conditionValue = this.newCondition.valueOption && this.newCondition.valueOption.id
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (!conditionValue && conditionValue !== false) return
|
|
582
|
+
|
|
583
|
+
const rule = this.schema.authorization[action][originalIndex]
|
|
584
|
+
if (!rule.match) {
|
|
585
|
+
this.$set(rule, 'match', {})
|
|
586
|
+
}
|
|
587
|
+
this.$set(rule.match, property, { [operator]: conditionValue })
|
|
588
|
+
|
|
589
|
+
this.cancelAddCondition()
|
|
590
|
+
},
|
|
234
591
|
},
|
|
235
592
|
}
|
|
236
593
|
</script>
|