qdadm 0.31.0 → 0.32.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  * For route guards or services, use authAdapter directly.
9
9
  *
10
10
  * Reactivity:
11
- * - Listens to auth:login, auth:logout, auth:impersonate signals
11
+ * - Listens to auth:login, auth:logout, auth:impersonate, auth:impersonate:stop signals
12
12
  * - User computed re-evaluates when these signals fire
13
13
  * - No polling or manual refresh needed
14
14
  *
@@ -54,6 +54,7 @@ export function useAuth() {
54
54
  cleanups.push(signals.on('auth:login', () => { authTick.value++ }))
55
55
  cleanups.push(signals.on('auth:logout', () => { authTick.value++ }))
56
56
  cleanups.push(signals.on('auth:impersonate', () => { authTick.value++ }))
57
+ cleanups.push(signals.on('auth:impersonate:stop', () => { authTick.value++ }))
57
58
  }
58
59
 
59
60
  // Cleanup signal subscriptions on unmount
@@ -39,8 +39,11 @@ export class AuthCollector extends Collector {
39
39
  this._ctx = null
40
40
  this._signalCleanups = []
41
41
  // Activity tracking for login/logout events
42
- this._hasActivity = false
43
- this._lastEvent = null // 'login' | 'logout' | null
42
+ // Keep recent events to show stacked (last N events)
43
+ this._recentEvents = [] // Array of { type: 'login'|'logout', timestamp: Date, seen: boolean }
44
+ this._maxEvents = 5
45
+ this._eventTtl = options.eventTtl ?? 60000 // Events expire after 60s by default
46
+ this._expiryTimer = null
44
47
  }
45
48
 
46
49
  /**
@@ -70,19 +73,81 @@ export class AuthCollector extends Collector {
70
73
  }
71
74
 
72
75
  // Listen to auth events and track activity
73
- const loginCleanup = signals.on('auth:login', () => {
74
- this._hasActivity = true
75
- this._lastEvent = 'login'
76
- this.notifyChange()
76
+ const loginCleanup = signals.on('auth:login', (event) => {
77
+ // QuarKernel wraps payload in KernelEvent - extract data from event.data
78
+ const data = event?.data || event
79
+ const user = data?.user || this._authAdapter?.getUser?.()
80
+ this._addEvent('login', { user })
77
81
  })
78
82
  this._signalCleanups.push(loginCleanup)
79
83
 
80
84
  const logoutCleanup = signals.on('auth:logout', () => {
81
- this._hasActivity = true
82
- this._lastEvent = 'logout'
83
- this.notifyChange()
85
+ this._addEvent('logout')
84
86
  })
85
87
  this._signalCleanups.push(logoutCleanup)
88
+
89
+ const impersonateCleanup = signals.on('auth:impersonate', (payload) => {
90
+ this._addEvent('impersonate', payload)
91
+ })
92
+ this._signalCleanups.push(impersonateCleanup)
93
+
94
+ const impersonateStopCleanup = signals.on('auth:impersonate:stop', (payload) => {
95
+ this._addEvent('impersonate-stop', payload)
96
+ })
97
+ this._signalCleanups.push(impersonateStopCleanup)
98
+ }
99
+
100
+ /**
101
+ * Add an auth event to the recent events list
102
+ * @param {string} type - 'login' | 'logout' | 'impersonate' | 'impersonate-stop'
103
+ * @param {object} [data] - Optional event data (e.g., { user } for impersonate)
104
+ * @private
105
+ */
106
+ _addEvent(type, data = null) {
107
+ this._recentEvents.unshift({
108
+ type,
109
+ timestamp: new Date(),
110
+ id: Date.now(), // Unique ID for Vue key
111
+ seen: false,
112
+ data
113
+ })
114
+ // Keep only last N events
115
+ if (this._recentEvents.length > this._maxEvents) {
116
+ this._recentEvents.pop()
117
+ }
118
+ this._scheduleExpiry()
119
+ this.notifyChange()
120
+ }
121
+
122
+ /**
123
+ * Schedule event expiry check
124
+ * @private
125
+ */
126
+ _scheduleExpiry() {
127
+ if (this._expiryTimer) return // Already scheduled
128
+ this._expiryTimer = setTimeout(() => {
129
+ this._expireOldEvents()
130
+ }, this._eventTtl)
131
+ }
132
+
133
+ /**
134
+ * Remove expired events
135
+ * @private
136
+ */
137
+ _expireOldEvents() {
138
+ this._expiryTimer = null
139
+ const now = Date.now()
140
+ const before = this._recentEvents.length
141
+ this._recentEvents = this._recentEvents.filter(
142
+ e => (now - e.timestamp.getTime()) < this._eventTtl
143
+ )
144
+ if (this._recentEvents.length < before) {
145
+ this.notifyChange()
146
+ }
147
+ // Reschedule if still have events
148
+ if (this._recentEvents.length > 0) {
149
+ this._scheduleExpiry()
150
+ }
86
151
  }
87
152
 
88
153
  /**
@@ -90,6 +155,10 @@ export class AuthCollector extends Collector {
90
155
  * @protected
91
156
  */
92
157
  _doUninstall() {
158
+ if (this._expiryTimer) {
159
+ clearTimeout(this._expiryTimer)
160
+ this._expiryTimer = null
161
+ }
93
162
  for (const cleanup of this._signalCleanups) {
94
163
  if (typeof cleanup === 'function') cleanup()
95
164
  }
@@ -99,11 +168,11 @@ export class AuthCollector extends Collector {
99
168
  }
100
169
 
101
170
  /**
102
- * Get badge - show 1 if there's unseen auth activity
103
- * @returns {number} 1 if activity unseen, 0 otherwise
171
+ * Get badge - show count of unseen auth events
172
+ * @returns {number} Number of unseen events
104
173
  */
105
174
  getBadge() {
106
- return this._hasActivity ? 1 : 0
175
+ return this._recentEvents.filter(e => !e.seen).length
107
176
  }
108
177
 
109
178
  /**
@@ -111,24 +180,41 @@ export class AuthCollector extends Collector {
111
180
  * @returns {boolean}
112
181
  */
113
182
  hasActivity() {
114
- return this._hasActivity
183
+ return this._recentEvents.some(e => !e.seen)
115
184
  }
116
185
 
117
186
  /**
118
- * Get the last auth event type
187
+ * Get all recent auth events (newest first)
188
+ * @returns {Array<{type: string, timestamp: Date, id: number, seen: boolean}>}
189
+ */
190
+ getRecentEvents() {
191
+ return this._recentEvents
192
+ }
193
+
194
+ /**
195
+ * Get the last auth event type (for backward compatibility)
119
196
  * @returns {string|null} 'login' | 'logout' | null
120
197
  */
121
198
  getLastEvent() {
122
- return this._lastEvent
199
+ return this._recentEvents[0]?.type || null
123
200
  }
124
201
 
125
202
  /**
126
- * Mark activity as seen (clear activity state)
203
+ * Mark all events as seen (badge resets but events stay visible)
127
204
  * Note: Does not call notifyChange() to avoid re-render loop
128
205
  */
129
206
  markSeen() {
130
- this._hasActivity = false
131
- this._lastEvent = null
207
+ for (const event of this._recentEvents) {
208
+ event.seen = true
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Clear all events (for explicit dismissal)
214
+ */
215
+ clearEvents() {
216
+ this._recentEvents = []
217
+ this.notifyChange()
132
218
  }
133
219
 
134
220
  /**
@@ -219,10 +219,11 @@ export class EntitiesCollector extends Collector {
219
219
  idField: manager.idField,
220
220
 
221
221
  // Storage info
222
+ // Prefer instance capabilities (may include requiresAuth) over static ones
222
223
  storage: {
223
224
  type: storage?.constructor?.name || 'None',
224
225
  endpoint: storage?.endpoint || storage?._endpoint || null,
225
- capabilities: storage?.constructor?.capabilities || {}
226
+ capabilities: storage?.capabilities || storage?.constructor?.capabilities || {}
226
227
  },
227
228
 
228
229
  // Cache info
@@ -373,4 +374,30 @@ export class EntitiesCollector extends Collector {
373
374
  markEntitySeen(entityName) {
374
375
  this._activeEntities.delete(entityName)
375
376
  }
377
+
378
+ /**
379
+ * Test fetch data from a specific entity
380
+ * Used to test auth protection - will throw 401 if not authenticated
381
+ * @param {string} entityName - Entity name
382
+ * @returns {Promise<{success: boolean, count?: number, error?: string, status?: number}>}
383
+ */
384
+ async testFetch(entityName) {
385
+ if (!this._orchestrator) {
386
+ return { success: false, error: 'No orchestrator' }
387
+ }
388
+ try {
389
+ const manager = this._orchestrator.get(entityName)
390
+ const result = await manager.storage.list({ page: 1, page_size: 1 })
391
+ return {
392
+ success: true,
393
+ count: result.total ?? result.items?.length ?? 0
394
+ }
395
+ } catch (e) {
396
+ return {
397
+ success: false,
398
+ error: e.message,
399
+ status: e.status
400
+ }
401
+ }
402
+ }
376
403
  }
@@ -10,14 +10,13 @@ const props = defineProps({
10
10
  entries: { type: Array, required: true }
11
11
  })
12
12
 
13
- // Mark activity as seen when panel is viewed
13
+ // Mark events as seen when panel is viewed (badge resets but events stay visible)
14
14
  onMounted(() => {
15
15
  props.collector.markSeen?.()
16
16
  })
17
17
 
18
- // Check for activity
19
- const hasActivity = computed(() => props.collector.hasActivity?.() || false)
20
- const lastEvent = computed(() => props.collector.getLastEvent?.())
18
+ // Get all recent events (stacked display)
19
+ const recentEvents = computed(() => props.collector.getRecentEvents?.() || [])
21
20
 
22
21
  function getIcon(type) {
23
22
  const icons = {
@@ -28,16 +27,60 @@ function getIcon(type) {
28
27
  }
29
28
  return icons[type] || 'pi-info-circle'
30
29
  }
30
+
31
+ function formatTime(date) {
32
+ return date.toLocaleTimeString('en-US', { hour12: false })
33
+ }
34
+
35
+ function getEventIcon(type) {
36
+ const icons = {
37
+ login: 'pi-sign-in',
38
+ logout: 'pi-sign-out',
39
+ impersonate: 'pi-user-edit',
40
+ 'impersonate-stop': 'pi-user'
41
+ }
42
+ return icons[type] || 'pi-info-circle'
43
+ }
44
+
45
+ function getEventLabel(event) {
46
+ if (event.type === 'login' && event.data) {
47
+ const user = event.data.user
48
+ const username = user?.username || user?.name || user?.email
49
+ if (username) {
50
+ return `User ${username} logged in`
51
+ }
52
+ }
53
+ if (event.type === 'impersonate' && event.data) {
54
+ // Payload structure: { target: { username }, original: { username } }
55
+ // Or signal object: { data: { target: { username } } }
56
+ const data = event.data.data || event.data
57
+ const username = data.target?.username
58
+ || data.username
59
+ || (typeof data === 'string' ? data : null)
60
+ if (username) {
61
+ return `Impersonating user ${username}`
62
+ }
63
+ }
64
+ if (event.type === 'impersonate-stop') {
65
+ const data = event.data?.data || event.data
66
+ const username = data?.original?.username
67
+ if (username) {
68
+ return `Back to ${username}`
69
+ }
70
+ }
71
+ const labels = {
72
+ login: 'User logged in',
73
+ logout: 'User logged out',
74
+ impersonate: 'Impersonating user',
75
+ 'impersonate-stop': 'Stopped impersonation'
76
+ }
77
+ return labels[event.type] || event.type
78
+ }
31
79
  </script>
32
80
 
33
81
  <template>
34
82
  <div class="auth-panel">
35
- <!-- Activity indicator -->
36
- <div v-if="hasActivity" class="auth-activity" :class="lastEvent">
37
- <i :class="['pi', lastEvent === 'login' ? 'pi-sign-in' : 'pi-sign-out']" />
38
- <span>{{ lastEvent === 'login' ? 'User logged in' : 'User logged out' }}</span>
39
- </div>
40
-
83
+ <!-- Invariant entries (user, token, permissions, adapter) -->
41
84
  <div v-for="(entry, idx) in entries" :key="idx" class="auth-item">
42
85
  <div class="auth-header">
43
86
  <i :class="['pi', getIcon(entry.type)]" />
@@ -46,6 +89,18 @@ function getIcon(type) {
46
89
  <div v-if="entry.message" class="auth-message">{{ entry.message }}</div>
47
90
  <ObjectTree v-else-if="entry.data" :data="entry.data" :maxDepth="4" />
48
91
  </div>
92
+
93
+ <!-- Recent auth events (stacked below, newest first) -->
94
+ <div
95
+ v-for="event in recentEvents"
96
+ :key="event.id"
97
+ class="auth-activity"
98
+ :class="event.type"
99
+ >
100
+ <i :class="['pi', getEventIcon(event.type)]" />
101
+ <span>{{ getEventLabel(event) }}</span>
102
+ <span class="auth-time">{{ formatTime(event.timestamp) }}</span>
103
+ </div>
49
104
  </div>
50
105
  </template>
51
106
 
@@ -77,6 +132,22 @@ function getIcon(type) {
77
132
  border-left: 3px solid #ef4444;
78
133
  color: #ef4444;
79
134
  }
135
+ .auth-activity.impersonate {
136
+ background: linear-gradient(90deg, rgba(249, 115, 22, 0.2) 0%, rgba(249, 115, 22, 0.05) 100%);
137
+ border-left: 3px solid #f97316;
138
+ color: #f97316;
139
+ }
140
+ .auth-activity.impersonate-stop {
141
+ background: linear-gradient(90deg, rgba(161, 161, 170, 0.2) 0%, rgba(161, 161, 170, 0.05) 100%);
142
+ border-left: 3px solid #a1a1aa;
143
+ color: #a1a1aa;
144
+ }
145
+ .auth-time {
146
+ margin-left: auto;
147
+ font-size: 10px;
148
+ opacity: 0.6;
149
+ font-weight: 400;
150
+ }
80
151
  @keyframes pulse {
81
152
  0%, 100% { opacity: 1; }
82
153
  50% { opacity: 0.7; }
@@ -19,6 +19,8 @@ onMounted(() => {
19
19
 
20
20
  const expandedEntities = ref(new Set())
21
21
  const loadingCache = ref(new Set())
22
+ const testingFetch = ref(new Set())
23
+ const testResults = ref(new Map()) // entityName -> { success, count, error, status }
22
24
 
23
25
  function toggleExpand(name) {
24
26
  if (expandedEntities.value.has(name)) {
@@ -56,12 +58,37 @@ function isLoading(name) {
56
58
  return loadingCache.value.has(name)
57
59
  }
58
60
 
61
+ function isTesting(name) {
62
+ return testingFetch.value.has(name)
63
+ }
64
+
65
+ function getTestResult(name) {
66
+ return testResults.value.get(name)
67
+ }
68
+
69
+ async function testFetch(entityName) {
70
+ if (testingFetch.value.has(entityName)) return
71
+ testingFetch.value.add(entityName)
72
+ testingFetch.value = new Set(testingFetch.value)
73
+ testResults.value.delete(entityName)
74
+ testResults.value = new Map(testResults.value)
75
+ try {
76
+ const result = await props.collector.testFetch(entityName)
77
+ testResults.value.set(entityName, result)
78
+ testResults.value = new Map(testResults.value)
79
+ } finally {
80
+ testingFetch.value.delete(entityName)
81
+ testingFetch.value = new Set(testingFetch.value)
82
+ }
83
+ }
84
+
59
85
  function getCapabilityIcon(cap) {
60
86
  const icons = {
61
87
  supportsTotal: 'pi-hashtag',
62
88
  supportsFilters: 'pi-filter',
63
89
  supportsPagination: 'pi-ellipsis-h',
64
- supportsCaching: 'pi-database'
90
+ supportsCaching: 'pi-database',
91
+ requiresAuth: 'pi-lock'
65
92
  }
66
93
  return icons[cap] || 'pi-question-circle'
67
94
  }
@@ -71,7 +98,8 @@ function getCapabilityLabel(cap) {
71
98
  supportsTotal: 'Total count',
72
99
  supportsFilters: 'Filters',
73
100
  supportsPagination: 'Pagination',
74
- supportsCaching: 'Caching'
101
+ supportsCaching: 'Caching',
102
+ requiresAuth: 'Requires authentication'
75
103
  }
76
104
  return labels[cap] || cap
77
105
  }
@@ -107,7 +135,7 @@ function getCapabilityLabel(cap) {
107
135
  />
108
136
  <i
109
137
  v-if="entity.permissions.readOnly"
110
- class="pi pi-lock perm-icon-readonly"
138
+ class="pi pi-eye perm-icon-readonly"
111
139
  title="Read-only entity"
112
140
  />
113
141
  </div>
@@ -143,7 +171,7 @@ function getCapabilityLabel(cap) {
143
171
  v-for="(enabled, cap) in entity.storage.capabilities"
144
172
  :key="cap"
145
173
  class="entity-cap"
146
- :class="[enabled ? 'entity-cap-enabled' : 'entity-cap-disabled']"
174
+ :class="[cap === 'requiresAuth' && enabled ? 'entity-cap-auth' : (enabled ? 'entity-cap-enabled' : 'entity-cap-disabled')]"
147
175
  :title="getCapabilityLabel(cap) + (enabled ? ' ✓' : ' ✗')"
148
176
  >
149
177
  <i :class="['pi', getCapabilityIcon(cap)]" />
@@ -180,6 +208,29 @@ function getCapabilityLabel(cap) {
180
208
  <span class="entity-key">Cache:</span>
181
209
  <span class="entity-value entity-cache-na">Disabled</span>
182
210
  </div>
211
+ <!-- Test Fetch row - always visible for testing auth protection -->
212
+ <div class="entity-row">
213
+ <span class="entity-key">Test:</span>
214
+ <span v-if="getTestResult(entity.name)" class="entity-test-result" :class="getTestResult(entity.name).success ? 'test-success' : 'test-error'">
215
+ <template v-if="getTestResult(entity.name).success">
216
+ <i class="pi pi-check-circle" />
217
+ {{ getTestResult(entity.name).count }} items
218
+ </template>
219
+ <template v-else>
220
+ <i class="pi pi-times-circle" />
221
+ {{ getTestResult(entity.name).status || 'ERR' }}: {{ getTestResult(entity.name).error }}
222
+ </template>
223
+ </span>
224
+ <span v-else class="entity-value entity-test-na">-</span>
225
+ <button
226
+ class="entity-test-btn"
227
+ :disabled="isTesting(entity.name)"
228
+ @click.stop="testFetch(entity.name)"
229
+ >
230
+ <i :class="['pi', isTesting(entity.name) ? 'pi-spin pi-spinner' : 'pi-download']" />
231
+ {{ isTesting(entity.name) ? 'Testing...' : 'Fetch' }}
232
+ </button>
233
+ </div>
183
234
  <div class="entity-row">
184
235
  <span class="entity-key">Fields:</span>
185
236
  <span class="entity-value">{{ entity.fields.count }} fields</span>
@@ -413,6 +464,47 @@ function getCapabilityLabel(cap) {
413
464
  background: rgba(239, 68, 68, 0.2);
414
465
  color: #ef4444;
415
466
  }
467
+ .entity-test-btn {
468
+ display: inline-flex;
469
+ align-items: center;
470
+ gap: 4px;
471
+ padding: 2px 8px;
472
+ background: #3b82f6;
473
+ border: none;
474
+ border-radius: 3px;
475
+ color: #fff;
476
+ cursor: pointer;
477
+ font-size: 10px;
478
+ margin-left: auto;
479
+ }
480
+ .entity-test-btn:hover {
481
+ background: #2563eb;
482
+ }
483
+ .entity-test-btn:disabled {
484
+ opacity: 0.5;
485
+ cursor: not-allowed;
486
+ }
487
+ .entity-test-result {
488
+ display: inline-flex;
489
+ align-items: center;
490
+ gap: 4px;
491
+ padding: 2px 6px;
492
+ border-radius: 3px;
493
+ font-size: 10px;
494
+ margin-left: 8px;
495
+ }
496
+ .test-success {
497
+ background: rgba(34, 197, 94, 0.2);
498
+ color: #22c55e;
499
+ }
500
+ .test-error {
501
+ background: rgba(239, 68, 68, 0.2);
502
+ color: #ef4444;
503
+ }
504
+ .entity-test-na {
505
+ color: #52525b;
506
+ font-style: italic;
507
+ }
416
508
 
417
509
  /* Collapsed summary */
418
510
  .entity-summary {
@@ -485,6 +577,10 @@ function getCapabilityLabel(cap) {
485
577
  background: rgba(239, 68, 68, 0.15);
486
578
  color: #71717a;
487
579
  }
580
+ .entity-cap-auth {
581
+ background: rgba(245, 158, 11, 0.2);
582
+ color: #f59e0b;
583
+ }
488
584
  .entity-required {
489
585
  color: #f59e0b;
490
586
  font-size: 10px;