qdadm 0.36.0 → 0.38.0
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/README.md +27 -174
- package/package.json +2 -1
- package/src/auth/SessionAuthAdapter.js +114 -3
- package/src/components/editors/PermissionEditor.vue +535 -0
- package/src/components/forms/FormField.vue +1 -11
- package/src/components/index.js +1 -0
- package/src/components/layout/AppLayout.vue +20 -8
- package/src/components/layout/defaults/DefaultToaster.vue +3 -3
- package/src/components/pages/LoginPage.vue +26 -5
- package/src/composables/useCurrentEntity.js +26 -17
- package/src/composables/useForm.js +7 -0
- package/src/composables/useFormPageBuilder.js +7 -0
- package/src/composables/useNavContext.js +30 -16
- package/src/core/index.js +0 -3
- package/src/debug/AuthCollector.js +175 -33
- package/src/debug/Collector.js +24 -2
- package/src/debug/EntitiesCollector.js +8 -0
- package/src/debug/SignalCollector.js +60 -2
- package/src/debug/components/panels/AuthPanel.vue +157 -27
- package/src/debug/components/panels/EntitiesPanel.vue +17 -1
- package/src/entity/EntityManager.js +183 -34
- package/src/entity/auth/EntityAuthAdapter.js +54 -46
- package/src/entity/auth/SecurityChecker.js +110 -42
- package/src/entity/auth/factory.js +11 -2
- package/src/entity/auth/factory.test.js +29 -0
- package/src/entity/storage/factory.test.js +6 -5
- package/src/index.js +3 -0
- package/src/kernel/Kernel.js +132 -21
- package/src/kernel/KernelContext.js +158 -0
- package/src/security/EntityRoleGranterAdapter.js +350 -0
- package/src/security/PermissionMatcher.js +148 -0
- package/src/security/PermissionRegistry.js +263 -0
- package/src/security/PersistableRoleGranterAdapter.js +618 -0
- package/src/security/RoleGranterAdapter.js +123 -0
- package/src/security/RoleGranterStorage.js +161 -0
- package/src/security/RolesManager.js +81 -0
- package/src/security/SecurityModule.js +73 -0
- package/src/security/StaticRoleGranterAdapter.js +114 -0
- package/src/security/UsersManager.js +122 -0
- package/src/security/index.js +45 -0
- package/src/security/pages/RoleForm.vue +212 -0
- package/src/security/pages/RoleList.vue +106 -0
- package/src/styles/main.css +62 -2
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* PermissionEditor - Fragmented autocomplete for permissions
|
|
4
|
+
*
|
|
5
|
+
* Single text input with segment-aware completion:
|
|
6
|
+
* - Type "au" → suggests "auth"
|
|
7
|
+
* - Tab/select → completes to "auth:"
|
|
8
|
+
* - Then suggests actions for that namespace
|
|
9
|
+
*
|
|
10
|
+
* Permission format: namespace:action
|
|
11
|
+
* - entity:books:read (namespace=entity:books, action=read)
|
|
12
|
+
* - auth:impersonate (namespace=auth, action=impersonate)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ref, computed, watch, nextTick } from 'vue'
|
|
16
|
+
import AutoComplete from 'primevue/autocomplete'
|
|
17
|
+
import Chip from 'primevue/chip'
|
|
18
|
+
import Button from 'primevue/button'
|
|
19
|
+
|
|
20
|
+
const props = defineProps({
|
|
21
|
+
modelValue: {
|
|
22
|
+
type: Array,
|
|
23
|
+
default: () => []
|
|
24
|
+
},
|
|
25
|
+
disabled: {
|
|
26
|
+
type: Boolean,
|
|
27
|
+
default: false
|
|
28
|
+
},
|
|
29
|
+
/**
|
|
30
|
+
* Permission registry instance
|
|
31
|
+
* @type {import('../../security/PermissionRegistry.js').PermissionRegistry}
|
|
32
|
+
*/
|
|
33
|
+
permissionRegistry: {
|
|
34
|
+
type: Object,
|
|
35
|
+
required: true
|
|
36
|
+
},
|
|
37
|
+
placeholder: {
|
|
38
|
+
type: String,
|
|
39
|
+
default: 'Type permission...'
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const emit = defineEmits(['update:modelValue'])
|
|
44
|
+
|
|
45
|
+
// Current input value
|
|
46
|
+
const inputValue = ref('')
|
|
47
|
+
const suggestions = ref([])
|
|
48
|
+
const autocompleteRef = ref(null)
|
|
49
|
+
|
|
50
|
+
// Get all permissions from registry
|
|
51
|
+
const allPermissions = computed(() => {
|
|
52
|
+
if (!props.permissionRegistry) {
|
|
53
|
+
console.warn('[PermissionEditor] No permissionRegistry provided')
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
const perms = props.permissionRegistry.getAll()
|
|
57
|
+
return perms
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Check if registry is available
|
|
61
|
+
const hasRegistry = computed(() => {
|
|
62
|
+
return props.permissionRegistry && allPermissions.value.length > 0
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Get all permission keys
|
|
66
|
+
const allPermissionKeys = computed(() => {
|
|
67
|
+
return allPermissions.value.map(p => p.key)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// Get all unique prefixes at each level
|
|
71
|
+
// e.g., from "entity:books:read" we get ["entity", "entity:books", "entity:books:read"]
|
|
72
|
+
const allPrefixes = computed(() => {
|
|
73
|
+
const prefixes = new Set()
|
|
74
|
+
for (const key of allPermissionKeys.value) {
|
|
75
|
+
const parts = key.split(':')
|
|
76
|
+
let current = ''
|
|
77
|
+
for (let i = 0; i < parts.length; i++) {
|
|
78
|
+
current = current ? current + ':' + parts[i] : parts[i]
|
|
79
|
+
prefixes.add(current)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return [...prefixes].sort()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Get unique actions across all namespaces
|
|
86
|
+
const allActions = computed(() => {
|
|
87
|
+
const actions = new Set()
|
|
88
|
+
for (const perm of allPermissions.value) {
|
|
89
|
+
actions.add(perm.action)
|
|
90
|
+
}
|
|
91
|
+
return [...actions].sort()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Generate suggestions based on current input
|
|
96
|
+
* Supports multi-level fragment completion:
|
|
97
|
+
* - "ent" → "entity:"
|
|
98
|
+
* - "entity:bo" → "entity:books:"
|
|
99
|
+
* - "entity:books:re" → "entity:books:read"
|
|
100
|
+
*/
|
|
101
|
+
function searchSuggestions(event) {
|
|
102
|
+
const query = event.query ?? ''
|
|
103
|
+
const queryLower = query.toLowerCase()
|
|
104
|
+
|
|
105
|
+
// Handle super wildcard **
|
|
106
|
+
if (query === '**') {
|
|
107
|
+
suggestions.value = [{
|
|
108
|
+
label: '**',
|
|
109
|
+
value: '**',
|
|
110
|
+
type: 'wildcard',
|
|
111
|
+
description: 'All permissions (super admin)'
|
|
112
|
+
}]
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Handle wildcard patterns
|
|
117
|
+
if (query.includes('*')) {
|
|
118
|
+
suggestions.value = generateWildcardSuggestions(query)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check if query ends with ':' - user wants next level suggestions
|
|
123
|
+
const endsWithColon = query.endsWith(':')
|
|
124
|
+
const baseQuery = endsWithColon ? query.slice(0, -1) : query
|
|
125
|
+
|
|
126
|
+
const results = []
|
|
127
|
+
|
|
128
|
+
// Add wildcard option if at start or after colon
|
|
129
|
+
if (query === '' || endsWithColon) {
|
|
130
|
+
results.push({
|
|
131
|
+
label: query + '*:',
|
|
132
|
+
value: query + '*:',
|
|
133
|
+
type: 'namespace',
|
|
134
|
+
description: 'Wildcard (all at this level)'
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Find matching prefixes that are NEXT level completions
|
|
139
|
+
for (const prefix of allPrefixes.value) {
|
|
140
|
+
const prefixLower = prefix.toLowerCase()
|
|
141
|
+
|
|
142
|
+
if (endsWithColon) {
|
|
143
|
+
// After "entity:", suggest "entity:books", "entity:users", etc.
|
|
144
|
+
if (prefixLower.startsWith(queryLower) && prefix !== query.slice(0, -1)) {
|
|
145
|
+
const isComplete = allPermissionKeys.value.includes(prefix)
|
|
146
|
+
results.push({
|
|
147
|
+
label: isComplete ? prefix : prefix + ':',
|
|
148
|
+
value: isComplete ? prefix : prefix + ':',
|
|
149
|
+
type: isComplete ? 'permission' : 'namespace',
|
|
150
|
+
description: isComplete ? getPermissionLabel(prefix) : `${countChildren(prefix)} sub-items`
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
// Partial match: "ent" matches "entity", "entity:books:re" matches "entity:books:read"
|
|
155
|
+
if (prefixLower.startsWith(queryLower) || (query === '' && true)) {
|
|
156
|
+
// Only suggest if it's a progression from current input
|
|
157
|
+
if (prefix.toLowerCase() !== queryLower) {
|
|
158
|
+
const isComplete = allPermissionKeys.value.includes(prefix)
|
|
159
|
+
// Find the next colon boundary for namespace suggestions
|
|
160
|
+
const nextColonIdx = prefix.indexOf(':', query.length)
|
|
161
|
+
const suggestionValue = nextColonIdx > -1 ? prefix.slice(0, nextColonIdx + 1) : (isComplete ? prefix : prefix + ':')
|
|
162
|
+
|
|
163
|
+
// Avoid duplicates
|
|
164
|
+
if (!results.some(r => r.value === suggestionValue)) {
|
|
165
|
+
results.push({
|
|
166
|
+
label: suggestionValue,
|
|
167
|
+
value: suggestionValue,
|
|
168
|
+
type: suggestionValue.endsWith(':') ? 'namespace' : 'permission',
|
|
169
|
+
description: isComplete && !suggestionValue.endsWith(':') ? getPermissionLabel(prefix) : `${countChildren(suggestionValue.replace(/:$/, ''))} sub-items`
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Sort: namespaces first, then permissions
|
|
178
|
+
results.sort((a, b) => {
|
|
179
|
+
if (a.type === 'namespace' && b.type !== 'namespace') return -1
|
|
180
|
+
if (a.type !== 'namespace' && b.type === 'namespace') return 1
|
|
181
|
+
return a.label.localeCompare(b.label)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
suggestions.value = results.slice(0, 20) // Limit to 20 suggestions
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Generate wildcard suggestions
|
|
189
|
+
*/
|
|
190
|
+
function generateWildcardSuggestions(query) {
|
|
191
|
+
const results = []
|
|
192
|
+
|
|
193
|
+
// *: pattern - suggest actions
|
|
194
|
+
if (query === '*:' || query.startsWith('*:')) {
|
|
195
|
+
const actionPart = query.slice(2).toLowerCase()
|
|
196
|
+
|
|
197
|
+
results.push({
|
|
198
|
+
label: '*:*',
|
|
199
|
+
value: '*:*',
|
|
200
|
+
type: 'wildcard',
|
|
201
|
+
description: 'All permissions'
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
for (const action of allActions.value) {
|
|
205
|
+
if (actionPart === '' || action.toLowerCase().startsWith(actionPart)) {
|
|
206
|
+
results.push({
|
|
207
|
+
label: '*:' + action,
|
|
208
|
+
value: '*:' + action,
|
|
209
|
+
type: 'wildcard-action',
|
|
210
|
+
description: `All ${action} permissions`
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} else if (query === '*') {
|
|
215
|
+
// Just * - suggest *: to continue
|
|
216
|
+
results.push({
|
|
217
|
+
label: '*:',
|
|
218
|
+
value: '*:',
|
|
219
|
+
type: 'namespace',
|
|
220
|
+
description: 'All namespaces (wildcard)'
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return results
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Count children of a prefix
|
|
229
|
+
*/
|
|
230
|
+
function countChildren(prefix) {
|
|
231
|
+
return allPrefixes.value.filter(p => p.startsWith(prefix + ':') || p === prefix).length
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get permission label
|
|
236
|
+
*/
|
|
237
|
+
function getPermissionLabel(key) {
|
|
238
|
+
const perm = props.permissionRegistry?.get(key)
|
|
239
|
+
return perm?.label || key
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Handle selection from dropdown
|
|
244
|
+
*/
|
|
245
|
+
function onSelect(event) {
|
|
246
|
+
const selected = event.value
|
|
247
|
+
|
|
248
|
+
if (!selected) return
|
|
249
|
+
|
|
250
|
+
// If selection ends with ':', it's a namespace - continue typing
|
|
251
|
+
if (selected.value.endsWith(':')) {
|
|
252
|
+
inputValue.value = selected.value
|
|
253
|
+
nextTick(() => {
|
|
254
|
+
const input = autocompleteRef.value?.$el?.querySelector('input')
|
|
255
|
+
input?.focus()
|
|
256
|
+
// Trigger new search for next level and show dropdown
|
|
257
|
+
searchSuggestions({ query: selected.value })
|
|
258
|
+
// Force show the dropdown with new suggestions
|
|
259
|
+
autocompleteRef.value?.show()
|
|
260
|
+
})
|
|
261
|
+
} else {
|
|
262
|
+
// Complete permission or wildcard - add to list
|
|
263
|
+
if (!props.modelValue.includes(selected.value)) {
|
|
264
|
+
emit('update:modelValue', [...props.modelValue, selected.value])
|
|
265
|
+
}
|
|
266
|
+
inputValue.value = ''
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Handle click on input - show suggestions if input ends with ':'
|
|
272
|
+
* This ensures the dropdown appears when clicking back into the field
|
|
273
|
+
*/
|
|
274
|
+
function onInputClick() {
|
|
275
|
+
if (inputValue.value.endsWith(':')) {
|
|
276
|
+
searchSuggestions({ query: inputValue.value })
|
|
277
|
+
nextTick(() => {
|
|
278
|
+
autocompleteRef.value?.show()
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Handle keyboard events for Tab completion
|
|
285
|
+
*/
|
|
286
|
+
function onKeydown(event) {
|
|
287
|
+
if (event.key === 'Tab' && suggestions.value.length > 0) {
|
|
288
|
+
event.preventDefault()
|
|
289
|
+
// Select first suggestion
|
|
290
|
+
const first = suggestions.value[0]
|
|
291
|
+
if (first) {
|
|
292
|
+
// If it's a namespace, onSelect will show the dropdown for next level
|
|
293
|
+
onSelect({ value: first })
|
|
294
|
+
}
|
|
295
|
+
} else if (event.key === 'Enter' && inputValue.value) {
|
|
296
|
+
event.preventDefault()
|
|
297
|
+
const value = inputValue.value
|
|
298
|
+
|
|
299
|
+
// Check if it's a valid complete permission or wildcard pattern
|
|
300
|
+
if (allPermissionKeys.value.includes(value) || value.includes('*')) {
|
|
301
|
+
if (!props.modelValue.includes(value)) {
|
|
302
|
+
emit('update:modelValue', [...props.modelValue, value])
|
|
303
|
+
}
|
|
304
|
+
inputValue.value = ''
|
|
305
|
+
} else if (suggestions.value.length > 0) {
|
|
306
|
+
// Select first suggestion on Enter
|
|
307
|
+
onSelect({ value: suggestions.value[0] })
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Remove a permission
|
|
314
|
+
*/
|
|
315
|
+
function removePermission(permKey) {
|
|
316
|
+
emit('update:modelValue', props.modelValue.filter(p => p !== permKey))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get display info for a permission
|
|
321
|
+
*/
|
|
322
|
+
function getPermissionInfo(permKey) {
|
|
323
|
+
// Handle super wildcard
|
|
324
|
+
if (permKey === '**') {
|
|
325
|
+
return {
|
|
326
|
+
namespace: '',
|
|
327
|
+
action: '**',
|
|
328
|
+
label: 'All permissions',
|
|
329
|
+
isWildcard: true
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Handle wildcard patterns
|
|
334
|
+
if (permKey.includes('*')) {
|
|
335
|
+
const parts = permKey.split(':')
|
|
336
|
+
const action = parts.pop()
|
|
337
|
+
const namespace = parts.join(':')
|
|
338
|
+
return {
|
|
339
|
+
namespace: namespace || '*',
|
|
340
|
+
action,
|
|
341
|
+
label: permKey,
|
|
342
|
+
isWildcard: true
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const perm = props.permissionRegistry?.get(permKey)
|
|
347
|
+
if (perm) {
|
|
348
|
+
return {
|
|
349
|
+
namespace: perm.namespace,
|
|
350
|
+
action: perm.action,
|
|
351
|
+
label: perm.label
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Fallback for unknown permissions
|
|
355
|
+
const parts = permKey.split(':')
|
|
356
|
+
return {
|
|
357
|
+
namespace: parts.slice(0, -1).join(':'),
|
|
358
|
+
action: parts[parts.length - 1],
|
|
359
|
+
label: permKey
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Custom template for suggestions
|
|
365
|
+
*/
|
|
366
|
+
function getSuggestionClass(item) {
|
|
367
|
+
return item.type === 'namespace' ? 'suggestion-namespace' : 'suggestion-permission'
|
|
368
|
+
}
|
|
369
|
+
</script>
|
|
370
|
+
|
|
371
|
+
<template>
|
|
372
|
+
<div class="permission-editor">
|
|
373
|
+
<!-- Selected permissions as chips -->
|
|
374
|
+
<div v-if="modelValue.length > 0" class="permission-chips">
|
|
375
|
+
<Chip
|
|
376
|
+
v-for="perm in modelValue"
|
|
377
|
+
:key="perm"
|
|
378
|
+
:label="perm"
|
|
379
|
+
removable
|
|
380
|
+
:disabled="disabled"
|
|
381
|
+
@remove="removePermission(perm)"
|
|
382
|
+
:class="['permission-chip', { 'wildcard-chip': getPermissionInfo(perm).isWildcard }]"
|
|
383
|
+
>
|
|
384
|
+
<template #default>
|
|
385
|
+
<template v-if="perm === '**'">
|
|
386
|
+
<span class="chip-wildcard">**</span>
|
|
387
|
+
</template>
|
|
388
|
+
<template v-else-if="getPermissionInfo(perm).namespace">
|
|
389
|
+
<span class="chip-namespace">{{ getPermissionInfo(perm).namespace }}:</span>
|
|
390
|
+
<span :class="['chip-action', { 'chip-wildcard': getPermissionInfo(perm).action === '*' }]">
|
|
391
|
+
{{ getPermissionInfo(perm).action }}
|
|
392
|
+
</span>
|
|
393
|
+
</template>
|
|
394
|
+
<template v-else>
|
|
395
|
+
<span class="chip-action">{{ perm }}</span>
|
|
396
|
+
</template>
|
|
397
|
+
</template>
|
|
398
|
+
</Chip>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
<!-- Autocomplete input -->
|
|
402
|
+
<div class="permission-input">
|
|
403
|
+
<template v-if="hasRegistry">
|
|
404
|
+
<AutoComplete
|
|
405
|
+
ref="autocompleteRef"
|
|
406
|
+
v-model="inputValue"
|
|
407
|
+
:suggestions="suggestions"
|
|
408
|
+
optionLabel="label"
|
|
409
|
+
:disabled="disabled"
|
|
410
|
+
:placeholder="placeholder"
|
|
411
|
+
:minLength="0"
|
|
412
|
+
completeOnFocus
|
|
413
|
+
@complete="searchSuggestions"
|
|
414
|
+
@item-select="onSelect"
|
|
415
|
+
@keydown="onKeydown"
|
|
416
|
+
@click="onInputClick"
|
|
417
|
+
dropdown
|
|
418
|
+
class="w-full"
|
|
419
|
+
>
|
|
420
|
+
<template #option="{ option }">
|
|
421
|
+
<div :class="['suggestion-item', getSuggestionClass(option)]">
|
|
422
|
+
<span class="suggestion-label">{{ option.label }}</span>
|
|
423
|
+
<span class="suggestion-desc">{{ option.description }}</span>
|
|
424
|
+
</div>
|
|
425
|
+
</template>
|
|
426
|
+
<template #empty>
|
|
427
|
+
<div class="suggestion-empty">
|
|
428
|
+
No matching permissions
|
|
429
|
+
</div>
|
|
430
|
+
</template>
|
|
431
|
+
</AutoComplete>
|
|
432
|
+
<small class="text-color-secondary mt-1 block">
|
|
433
|
+
Type permission path (e.g., "entity:books:read"), Tab to complete
|
|
434
|
+
</small>
|
|
435
|
+
</template>
|
|
436
|
+
<template v-else>
|
|
437
|
+
<div class="no-registry">
|
|
438
|
+
<i class="pi pi-info-circle"></i>
|
|
439
|
+
<span>No permissions registered in the system</span>
|
|
440
|
+
</div>
|
|
441
|
+
</template>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
</template>
|
|
445
|
+
|
|
446
|
+
<style scoped>
|
|
447
|
+
/* Uses global .editor-box pattern */
|
|
448
|
+
.permission-editor {
|
|
449
|
+
border: 1px solid var(--p-surface-200);
|
|
450
|
+
border-radius: 0.5rem;
|
|
451
|
+
padding: 0.75rem;
|
|
452
|
+
background: var(--p-surface-50);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/* Uses global .editor-chips pattern */
|
|
456
|
+
.permission-chips {
|
|
457
|
+
display: flex;
|
|
458
|
+
flex-wrap: wrap;
|
|
459
|
+
gap: 0.5rem;
|
|
460
|
+
margin-bottom: 0.75rem;
|
|
461
|
+
padding-bottom: 0.75rem;
|
|
462
|
+
border-bottom: 1px solid var(--p-surface-200);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.permission-chip {
|
|
466
|
+
font-family: monospace;
|
|
467
|
+
font-size: 0.875rem;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/* .chip-namespace, .chip-action, .chip-wildcard are global (main.css) */
|
|
471
|
+
|
|
472
|
+
.wildcard-chip {
|
|
473
|
+
background: var(--p-orange-50) !important;
|
|
474
|
+
border-color: var(--p-orange-200) !important;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.permission-input {
|
|
478
|
+
display: flex;
|
|
479
|
+
flex-direction: column;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.suggestion-item {
|
|
483
|
+
display: flex;
|
|
484
|
+
justify-content: space-between;
|
|
485
|
+
align-items: center;
|
|
486
|
+
padding: 0.25rem 0;
|
|
487
|
+
gap: 1rem;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.suggestion-namespace .suggestion-label,
|
|
491
|
+
.suggestion-permission .suggestion-label {
|
|
492
|
+
font-family: monospace;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.suggestion-namespace .suggestion-label {
|
|
496
|
+
color: var(--p-primary-600);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.suggestion-permission .suggestion-label {
|
|
500
|
+
font-weight: 500;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.suggestion-desc {
|
|
504
|
+
font-size: 0.75rem;
|
|
505
|
+
color: var(--p-surface-500);
|
|
506
|
+
white-space: nowrap;
|
|
507
|
+
overflow: hidden;
|
|
508
|
+
text-overflow: ellipsis;
|
|
509
|
+
max-width: 200px;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.suggestion-empty {
|
|
513
|
+
padding: 0.5rem;
|
|
514
|
+
color: var(--p-surface-400);
|
|
515
|
+
font-style: italic;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
.no-registry {
|
|
519
|
+
display: flex;
|
|
520
|
+
align-items: center;
|
|
521
|
+
gap: 0.5rem;
|
|
522
|
+
padding: 0.75rem;
|
|
523
|
+
color: var(--p-surface-500);
|
|
524
|
+
font-style: italic;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
:deep(.p-autocomplete) {
|
|
528
|
+
width: 100%;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
:deep(.p-autocomplete-input) {
|
|
532
|
+
width: 100%;
|
|
533
|
+
font-family: monospace;
|
|
534
|
+
}
|
|
535
|
+
</style>
|
|
@@ -92,17 +92,7 @@ function onBlur() {
|
|
|
92
92
|
</template>
|
|
93
93
|
|
|
94
94
|
<style scoped>
|
|
95
|
-
.field-hint
|
|
96
|
-
color: var(--p-surface-500);
|
|
97
|
-
margin-top: 0.25rem;
|
|
98
|
-
display: block;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
.field-error {
|
|
102
|
-
color: var(--p-red-500);
|
|
103
|
-
margin-top: 0.25rem;
|
|
104
|
-
display: block;
|
|
105
|
-
}
|
|
95
|
+
/* .field-hint and .field-error are global (main.css) */
|
|
106
96
|
|
|
107
97
|
.field-invalid :deep(input),
|
|
108
98
|
.field-invalid :deep(textarea),
|
package/src/components/index.js
CHANGED
|
@@ -36,6 +36,7 @@ export { default as FilterBar } from './lists/FilterBar.vue'
|
|
|
36
36
|
export { default as KeyValueEditor } from './editors/KeyValueEditor.vue'
|
|
37
37
|
export { default as LanguageEditor } from './editors/LanguageEditor.vue'
|
|
38
38
|
export { default as ScopeEditor } from './editors/ScopeEditor.vue'
|
|
39
|
+
export { default as PermissionEditor } from './editors/PermissionEditor.vue'
|
|
39
40
|
export { default as JsonEditorFoldable } from './editors/JsonEditorFoldable.vue'
|
|
40
41
|
export { default as JsonViewer } from './editors/JsonViewer.vue'
|
|
41
42
|
|
|
@@ -159,20 +159,32 @@ function handleLogout() {
|
|
|
159
159
|
const slots = useSlots()
|
|
160
160
|
const hasSlotContent = computed(() => !!slots.default)
|
|
161
161
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
const
|
|
165
|
-
|
|
162
|
+
// Breadcrumb entity data - multi-level support for parent/child entities
|
|
163
|
+
// Map: level -> entityData (level 1 = parent, level 2 = child, etc.)
|
|
164
|
+
const breadcrumbEntities = ref(new Map())
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Set entity data for breadcrumb at a specific level
|
|
168
|
+
* @param {object} data - Entity data
|
|
169
|
+
* @param {number} level - Breadcrumb level (1 = main entity, 2 = child, etc.)
|
|
170
|
+
*/
|
|
171
|
+
function setBreadcrumbEntity(data, level = 1) {
|
|
172
|
+
const newMap = new Map(breadcrumbEntities.value)
|
|
173
|
+
newMap.set(level, data)
|
|
174
|
+
breadcrumbEntities.value = newMap
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
provide('qdadmSetBreadcrumbEntity', setBreadcrumbEntity)
|
|
178
|
+
provide('qdadmBreadcrumbEntities', breadcrumbEntities)
|
|
166
179
|
|
|
167
180
|
// Clear entity data on route change (before new page mounts)
|
|
168
181
|
watch(() => route.fullPath, () => {
|
|
169
|
-
|
|
182
|
+
breadcrumbEntities.value = new Map()
|
|
170
183
|
})
|
|
171
184
|
|
|
172
185
|
// Navigation context (breadcrumb + navlinks from route config)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
})
|
|
186
|
+
// Pass breadcrumbEntities directly since we're in the same component that provides it
|
|
187
|
+
const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks } = useNavContext({ breadcrumbEntities })
|
|
176
188
|
|
|
177
189
|
// Allow child pages to override breadcrumb/navlinks via provide/inject
|
|
178
190
|
const breadcrumbOverride = ref(null)
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
* Renders the Toast component for notifications.
|
|
6
6
|
* Uses PrimeVue Toast with default positioning.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* NOTE: For pages outside BaseLayout (like LoginPage),
|
|
9
|
+
* apps must include Toast in their App.vue root component.
|
|
10
10
|
*/
|
|
11
11
|
import Toast from 'primevue/toast'
|
|
12
12
|
</script>
|
|
13
13
|
|
|
14
14
|
<template>
|
|
15
|
-
<Toast />
|
|
15
|
+
<Toast position="top-right" />
|
|
16
16
|
</template>
|
|
@@ -131,22 +131,43 @@ async function handleLogin() {
|
|
|
131
131
|
password: password.value
|
|
132
132
|
})
|
|
133
133
|
|
|
134
|
+
toast.add({
|
|
135
|
+
severity: 'success',
|
|
136
|
+
summary: 'Welcome',
|
|
137
|
+
detail: `Logged in as ${result.user?.username || result.user?.email || username.value}`,
|
|
138
|
+
life: 3000
|
|
139
|
+
})
|
|
140
|
+
|
|
134
141
|
// Emit business signal if enabled
|
|
135
142
|
if (props.emitSignal && orchestrator?.signals) {
|
|
136
143
|
orchestrator.signals.emit('auth:login', { user: result.user })
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
// Emit component event
|
|
140
146
|
emit('login', result)
|
|
141
|
-
|
|
142
147
|
router.push(props.redirectTo)
|
|
143
148
|
} catch (error) {
|
|
149
|
+
password.value = ''
|
|
150
|
+
|
|
151
|
+
const message = error.response?.data?.error?.message
|
|
152
|
+
|| error.response?.data?.message
|
|
153
|
+
|| error.message
|
|
154
|
+
|| 'Invalid credentials'
|
|
155
|
+
|
|
144
156
|
toast.add({
|
|
145
157
|
severity: 'error',
|
|
146
158
|
summary: 'Login Failed',
|
|
147
|
-
detail:
|
|
148
|
-
life:
|
|
159
|
+
detail: message,
|
|
160
|
+
life: 5000
|
|
149
161
|
})
|
|
162
|
+
|
|
163
|
+
if (orchestrator?.signals) {
|
|
164
|
+
orchestrator.signals.emit('auth:login:error', {
|
|
165
|
+
username: username.value,
|
|
166
|
+
error: message,
|
|
167
|
+
status: error.response?.status
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
150
171
|
emit('error', error)
|
|
151
172
|
} finally {
|
|
152
173
|
loading.value = false
|
|
@@ -167,7 +188,7 @@ async function handleLogin() {
|
|
|
167
188
|
</div>
|
|
168
189
|
</template>
|
|
169
190
|
<template #content>
|
|
170
|
-
<form @submit.prevent="handleLogin" class="qdadm-login-form">
|
|
191
|
+
<form @submit.prevent="handleLogin" class="qdadm-login-form" autocomplete="off">
|
|
171
192
|
<div class="qdadm-login-field">
|
|
172
193
|
<label for="qdadm-username">{{ usernameLabel }}</label>
|
|
173
194
|
<InputText
|