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.
Files changed (43) hide show
  1. package/README.md +27 -174
  2. package/package.json +2 -1
  3. package/src/auth/SessionAuthAdapter.js +114 -3
  4. package/src/components/editors/PermissionEditor.vue +535 -0
  5. package/src/components/forms/FormField.vue +1 -11
  6. package/src/components/index.js +1 -0
  7. package/src/components/layout/AppLayout.vue +20 -8
  8. package/src/components/layout/defaults/DefaultToaster.vue +3 -3
  9. package/src/components/pages/LoginPage.vue +26 -5
  10. package/src/composables/useCurrentEntity.js +26 -17
  11. package/src/composables/useForm.js +7 -0
  12. package/src/composables/useFormPageBuilder.js +7 -0
  13. package/src/composables/useNavContext.js +30 -16
  14. package/src/core/index.js +0 -3
  15. package/src/debug/AuthCollector.js +175 -33
  16. package/src/debug/Collector.js +24 -2
  17. package/src/debug/EntitiesCollector.js +8 -0
  18. package/src/debug/SignalCollector.js +60 -2
  19. package/src/debug/components/panels/AuthPanel.vue +157 -27
  20. package/src/debug/components/panels/EntitiesPanel.vue +17 -1
  21. package/src/entity/EntityManager.js +183 -34
  22. package/src/entity/auth/EntityAuthAdapter.js +54 -46
  23. package/src/entity/auth/SecurityChecker.js +110 -42
  24. package/src/entity/auth/factory.js +11 -2
  25. package/src/entity/auth/factory.test.js +29 -0
  26. package/src/entity/storage/factory.test.js +6 -5
  27. package/src/index.js +3 -0
  28. package/src/kernel/Kernel.js +132 -21
  29. package/src/kernel/KernelContext.js +158 -0
  30. package/src/security/EntityRoleGranterAdapter.js +350 -0
  31. package/src/security/PermissionMatcher.js +148 -0
  32. package/src/security/PermissionRegistry.js +263 -0
  33. package/src/security/PersistableRoleGranterAdapter.js +618 -0
  34. package/src/security/RoleGranterAdapter.js +123 -0
  35. package/src/security/RoleGranterStorage.js +161 -0
  36. package/src/security/RolesManager.js +81 -0
  37. package/src/security/SecurityModule.js +73 -0
  38. package/src/security/StaticRoleGranterAdapter.js +114 -0
  39. package/src/security/UsersManager.js +122 -0
  40. package/src/security/index.js +45 -0
  41. package/src/security/pages/RoleForm.vue +212 -0
  42. package/src/security/pages/RoleList.vue +106 -0
  43. 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),
@@ -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
- // Current page entity data - pages can inject and set this to avoid double fetch
163
- // When a page loads an entity, it sets this ref, and useNavContext uses it for breadcrumb
164
- const currentEntityData = ref(null)
165
- provide('qdadmCurrentEntityData', currentEntityData)
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
- currentEntityData.value = null
182
+ breadcrumbEntities.value = new Map()
170
183
  })
171
184
 
172
185
  // Navigation context (breadcrumb + navlinks from route config)
173
- const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks } = useNavContext({
174
- entityData: currentEntityData
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
- * This is the default component rendered in the "toaster" zone
9
- * when no blocks are registered.
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: error.message || 'Invalid credentials',
148
- life: 3000
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