@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.
Files changed (30) hide show
  1. package/dist/nextcloud-vue.cjs.js +20518 -1290
  2. package/dist/nextcloud-vue.cjs.js.map +1 -1
  3. package/dist/nextcloud-vue.css +470 -185
  4. package/dist/nextcloud-vue.esm.js +20518 -1290
  5. package/dist/nextcloud-vue.esm.js.map +1 -1
  6. package/l10n/en.json +255 -2
  7. package/l10n/nl.json +247 -2
  8. package/package.json +2 -1
  9. package/src/components/CnAdvancedFormDialog/CnDataTab.vue +1 -4
  10. package/src/components/CnContextMenu/CnContextMenu.vue +1 -1
  11. package/src/components/CnDataTable/CnDataTable.vue +7 -2
  12. package/src/components/CnInfoWidget/CnInfoWidget.vue +0 -1
  13. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +2 -2
  14. package/src/components/CnObjectMetadataWidget/CnObjectMetadataWidget.vue +2 -2
  15. package/src/components/CnRowActions/CnRowActions.vue +1 -1
  16. package/src/components/CnSchemaFormDialog/CnSchemaConfigurationTab.vue +36 -34
  17. package/src/components/CnSchemaFormDialog/CnSchemaFormDialog.vue +47 -36
  18. package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +29 -22
  19. package/src/components/CnSchemaFormDialog/CnSchemaPropertyActions.vue +170 -163
  20. package/src/components/CnSchemaFormDialog/CnSchemaSecurityTab.vue +473 -116
  21. package/src/components/CnStatsBlock/CnStatsBlock.vue +18 -18
  22. package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +12 -0
  23. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +7 -7
  24. package/src/composables/useContextMenu.js +1 -1
  25. package/src/css/CnSchemaFormDialog.css +258 -2
  26. package/src/css/dashboard.css +1 -0
  27. package/src/css/detail-page.css +5 -5
  28. package/src/css/index.css +1 -0
  29. package/src/css/patches.css +20 -0
  30. 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-Based Access Control (RBAC)</strong></p>
5
- <p>Configure which Nextcloud user groups can perform CRUD operations on objects of this schema.</p>
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...</span>
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 Permissions</h3>
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
- <NcCheckboxRadioSwitch
96
- :checked="hasGroupPermission(group.id, 'create')"
97
- @update:checked="updateGroupPermission(group.id, 'create', $event)" />
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 Access:</strong> No specific permissions set - all users can perform all operations.</p>
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 Schema:</strong> Access is limited to specified groups only.</p>
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)') }} &nbsp;
108
+ <code>$userId</code> {{ t('nextcloud-vue', '(current user ID)') }} &nbsp;
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
- // NcNoteCard,
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>