qdadm 0.32.0 → 0.35.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.32.0",
3
+ "version": "0.35.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "exports": {
22
22
  ".": "./src/index.js",
23
+ "./auth": "./src/auth/index.js",
23
24
  "./composables": "./src/composables/index.js",
24
25
  "./components": "./src/components/index.js",
25
26
  "./editors": "./src/editors/index.js",
@@ -0,0 +1,254 @@
1
+ /**
2
+ * SessionAuthAdapter - Base class for user authentication
3
+ *
4
+ * Applications extend this class to implement their authentication logic.
5
+ * The adapter handles user sessions: login, logout, token management.
6
+ *
7
+ * This is different from EntityAuthAdapter which handles entity-level permissions.
8
+ *
9
+ * @example
10
+ * ```js
11
+ * class MyAuthAdapter extends SessionAuthAdapter {
12
+ * async login({ username, password }) {
13
+ * const response = await api.post('/auth/login', { username, password })
14
+ * this.setSession(response.token, response.user)
15
+ * return { token: response.token, user: response.user }
16
+ * }
17
+ *
18
+ * logout() {
19
+ * this.clearSession()
20
+ * }
21
+ * }
22
+ * ```
23
+ *
24
+ * @abstract
25
+ */
26
+ export class SessionAuthAdapter {
27
+ /**
28
+ * Internal session state
29
+ * @protected
30
+ */
31
+ _token = null
32
+ _user = null
33
+
34
+ /**
35
+ * Authenticate user with credentials
36
+ *
37
+ * @param {object} credentials - Login credentials
38
+ * @param {string} credentials.username - Username or email
39
+ * @param {string} credentials.password - Password
40
+ * @returns {Promise<{token: string, user: object}>} Session data
41
+ * @throws {Error} If authentication fails
42
+ *
43
+ * @example
44
+ * const { token, user } = await adapter.login({ username: 'admin', password: 'secret' })
45
+ */
46
+ async login(credentials) {
47
+ throw new Error('[SessionAuthAdapter] login() must be implemented by subclass')
48
+ }
49
+
50
+ /**
51
+ * End the current session
52
+ *
53
+ * Should clear all session data (tokens, user info).
54
+ * Called by AppLayout logout button and useAuth().logout()
55
+ */
56
+ logout() {
57
+ throw new Error('[SessionAuthAdapter] logout() must be implemented by subclass')
58
+ }
59
+
60
+ /**
61
+ * Check if user is currently authenticated
62
+ *
63
+ * @returns {boolean} True if user has valid session
64
+ *
65
+ * @example
66
+ * if (adapter.isAuthenticated()) {
67
+ * // Show dashboard
68
+ * } else {
69
+ * // Redirect to login
70
+ * }
71
+ */
72
+ isAuthenticated() {
73
+ throw new Error('[SessionAuthAdapter] isAuthenticated() must be implemented by subclass')
74
+ }
75
+
76
+ /**
77
+ * Get the current authentication token
78
+ *
79
+ * Used by API clients to include in request headers.
80
+ *
81
+ * @returns {string|null} JWT token or null if not authenticated
82
+ *
83
+ * @example
84
+ * const token = adapter.getToken()
85
+ * fetch('/api/data', {
86
+ * headers: { Authorization: `Bearer ${token}` }
87
+ * })
88
+ */
89
+ getToken() {
90
+ throw new Error('[SessionAuthAdapter] getToken() must be implemented by subclass')
91
+ }
92
+
93
+ /**
94
+ * Get the current user object
95
+ *
96
+ * Returns user data from the session. The shape depends on your backend.
97
+ *
98
+ * @returns {object|null} User object or null if not authenticated
99
+ *
100
+ * @example
101
+ * const user = adapter.getUser()
102
+ * console.log(user.username, user.email, user.roles)
103
+ */
104
+ getUser() {
105
+ throw new Error('[SessionAuthAdapter] getUser() must be implemented by subclass')
106
+ }
107
+
108
+ /**
109
+ * Synchronous user getter (optional)
110
+ *
111
+ * Some implementations prefer a property instead of method.
112
+ * useAuth() supports both patterns.
113
+ *
114
+ * @type {object|null}
115
+ */
116
+ get user() {
117
+ return this.getUser()
118
+ }
119
+
120
+ // ─────────────────────────────────────────────────────────────────
121
+ // Helper methods for subclasses
122
+ // ─────────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Set session data (helper for subclasses)
126
+ *
127
+ * @param {string} token - Authentication token
128
+ * @param {object} user - User object
129
+ * @protected
130
+ */
131
+ setSession(token, user) {
132
+ this._token = token
133
+ this._user = user
134
+ }
135
+
136
+ /**
137
+ * Clear session data (helper for subclasses)
138
+ *
139
+ * @protected
140
+ */
141
+ clearSession() {
142
+ this._token = null
143
+ this._user = null
144
+ }
145
+
146
+ /**
147
+ * Validate that the adapter is properly configured
148
+ *
149
+ * Called during bootstrap to catch configuration errors early.
150
+ *
151
+ * @throws {Error} If required methods are not implemented
152
+ */
153
+ static validate(adapter) {
154
+ const required = ['login', 'logout', 'isAuthenticated', 'getToken', 'getUser']
155
+ const missing = required.filter(method => typeof adapter[method] !== 'function')
156
+
157
+ if (missing.length > 0) {
158
+ throw new Error(
159
+ `[SessionAuthAdapter] Missing required methods: ${missing.join(', ')}\n` +
160
+ 'Ensure your authAdapter implements all required methods or extends SessionAuthAdapter.'
161
+ )
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * LocalStorage-based SessionAuthAdapter implementation
168
+ *
169
+ * Ready-to-use adapter that stores session in localStorage.
170
+ * Extend and override login() to add your API call.
171
+ *
172
+ * @example
173
+ * ```js
174
+ * class MyAuthAdapter extends LocalStorageSessionAuthAdapter {
175
+ * constructor() {
176
+ * super('my_app_auth') // localStorage key
177
+ * }
178
+ *
179
+ * async login({ username, password }) {
180
+ * const res = await fetch('/api/login', {
181
+ * method: 'POST',
182
+ * body: JSON.stringify({ username, password })
183
+ * })
184
+ * const data = await res.json()
185
+ * this.setSession(data.token, data.user)
186
+ * this.persist()
187
+ * return data
188
+ * }
189
+ * }
190
+ * ```
191
+ */
192
+ export class LocalStorageSessionAuthAdapter extends SessionAuthAdapter {
193
+ /**
194
+ * @param {string} storageKey - localStorage key for session data
195
+ */
196
+ constructor(storageKey = 'qdadm_auth') {
197
+ super()
198
+ this._storageKey = storageKey
199
+ this._restore()
200
+ }
201
+
202
+ /**
203
+ * Restore session from localStorage on init
204
+ * @private
205
+ */
206
+ _restore() {
207
+ try {
208
+ const stored = localStorage.getItem(this._storageKey)
209
+ if (stored) {
210
+ const { token, user } = JSON.parse(stored)
211
+ this._token = token
212
+ this._user = user
213
+ }
214
+ } catch {
215
+ // Invalid stored data, ignore
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Persist session to localStorage
221
+ * Call after login() to save session
222
+ * @protected
223
+ */
224
+ persist() {
225
+ if (this._token && this._user) {
226
+ localStorage.setItem(this._storageKey, JSON.stringify({
227
+ token: this._token,
228
+ user: this._user
229
+ }))
230
+ } else {
231
+ localStorage.removeItem(this._storageKey)
232
+ }
233
+ }
234
+
235
+ // Arrow functions to preserve `this` when used as callbacks
236
+ logout = () => {
237
+ this.clearSession()
238
+ localStorage.removeItem(this._storageKey)
239
+ }
240
+
241
+ isAuthenticated = () => {
242
+ return !!this._token
243
+ }
244
+
245
+ getToken = () => {
246
+ return this._token
247
+ }
248
+
249
+ getUser = () => {
250
+ return this._user
251
+ }
252
+ }
253
+
254
+ export default SessionAuthAdapter
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Auth module - User session authentication
3
+ *
4
+ * For entity-level permissions, see entity/auth/EntityAuthAdapter
5
+ */
6
+
7
+ export {
8
+ SessionAuthAdapter,
9
+ LocalStorageSessionAuthAdapter,
10
+ } from './SessionAuthAdapter.js'
@@ -35,6 +35,7 @@ const route = useRoute()
35
35
  const app = useApp()
36
36
  const { navSections, isNavActive, sectionHasActiveItem, handleNavClick } = useNavigation()
37
37
  const { isAuthenticated, user, logout, authEnabled } = useAuth()
38
+ const signals = inject('qdadmSignals', null)
38
39
 
39
40
  // LocalStorage key for collapsed sections state (namespaced by app)
40
41
  const STORAGE_KEY = computed(() => `${app.shortName.toLowerCase()}_nav_collapsed`)
@@ -150,6 +151,7 @@ const userSubtitle = computed(() => {
150
151
 
151
152
  function handleLogout() {
152
153
  logout()
154
+ signals?.emit('auth:logout', { reason: 'user' })
153
155
  router.push({ name: 'login' })
154
156
  }
155
157
 
@@ -90,11 +90,12 @@ const props = defineProps({
90
90
  default: ''
91
91
  },
92
92
  /**
93
- * Emit business signal on login (requires orchestrator)
93
+ * Emit auth:login signal on successful login
94
+ * Required for debug bar auth tracking and other signal listeners
94
95
  */
95
96
  emitSignal: {
96
97
  type: Boolean,
97
- default: false
98
+ default: true
98
99
  }
99
100
  })
100
101
 
@@ -120,7 +120,13 @@ export function useFormPageBuilder(config = {}) {
120
120
  // Get EntityManager via orchestrator
121
121
  const orchestrator = inject('qdadmOrchestrator')
122
122
  if (!orchestrator) {
123
- throw new Error('[qdadm] Orchestrator not provided. Make sure to use createQdadm() with entityFactory.')
123
+ throw new Error(
124
+ '[qdadm] Orchestrator not provided.\n' +
125
+ 'Possible causes:\n' +
126
+ '1. Kernel not initialized - ensure createKernel().createApp() is called before mounting\n' +
127
+ '2. Component used outside of qdadm app context\n' +
128
+ '3. Missing entityFactory in Kernel options'
129
+ )
124
130
  }
125
131
  const manager = orchestrator.get(entity)
126
132
 
@@ -143,7 +143,13 @@ export function useListPageBuilder(config = {}) {
143
143
  // Get EntityManager via orchestrator
144
144
  const orchestrator = inject('qdadmOrchestrator')
145
145
  if (!orchestrator) {
146
- throw new Error('[qdadm] Orchestrator not provided. Make sure to use createQdadm() with entityFactory.')
146
+ throw new Error(
147
+ '[qdadm] Orchestrator not provided.\n' +
148
+ 'Possible causes:\n' +
149
+ '1. Kernel not initialized - ensure createKernel().createApp() is called before mounting\n' +
150
+ '2. Component used outside of qdadm app context\n' +
151
+ '3. Missing entityFactory in Kernel options'
152
+ )
147
153
  }
148
154
  const manager = orchestrator.get(entity)
149
155
 
@@ -179,10 +179,11 @@ export class Collector {
179
179
 
180
180
  /**
181
181
  * Get all entries
182
+ * Returns a shallow copy to trigger Vue reactivity when used in computed
182
183
  * @returns {Array<object>} All recorded entries
183
184
  */
184
185
  getEntries() {
185
- return this.entries
186
+ return [...this.entries]
186
187
  }
187
188
 
188
189
  /**
@@ -77,12 +77,12 @@ export class EntitiesCollector extends Collector {
77
77
  })
78
78
  this._signalCleanups.push(entityCleanup)
79
79
 
80
- // Listen to cache invalidation
81
- const cacheCleanup = signals.on('cache:entity:invalidated', () => {
80
+ // Listen to entity data changes (CRUD operations)
81
+ const dataCleanup = signals.on('entity:data-invalidate', () => {
82
82
  this._lastUpdate = Date.now()
83
83
  this.notifyChange()
84
84
  })
85
- this._signalCleanups.push(cacheCleanup)
85
+ this._signalCleanups.push(dataCleanup)
86
86
  }
87
87
 
88
88
  /**
@@ -248,6 +248,9 @@ export class EntitiesCollector extends Collector {
248
248
  canList: manager.canList?.() ?? true
249
249
  },
250
250
 
251
+ // Auth sensitivity (auto-invalidates on auth events)
252
+ authSensitive: manager._authSensitive ?? false,
253
+
251
254
  // Warmup
252
255
  warmup: {
253
256
  enabled: manager.warmupEnabled ?? false
@@ -18,8 +18,8 @@ import { Collector } from './Collector.js'
18
18
  * Collector for SignalBus events
19
19
  *
20
20
  * Records all signals with their name, data, and source for debugging.
21
- * Uses the `*:*` wildcard pattern to capture all signals following the
22
- * domain:action naming convention.
21
+ * Uses the `**` wildcard pattern to capture all signals including
22
+ * multi-segment names (e.g., entity:data-invalidate, auth:impersonate:start).
23
23
  */
24
24
  export class SignalCollector extends Collector {
25
25
  /**
@@ -43,8 +43,8 @@ export class SignalCollector extends Collector {
43
43
 
44
44
  // Subscribe to all signals using wildcard pattern
45
45
  // QuarKernel supports wildcards with the configured delimiter (:)
46
- // '*:*' matches any domain:action signal
47
- this._unsubscribe = ctx.signals.on('*:*', (event) => {
46
+ // '**' matches all signals including multi-segment (entity:data-invalidate)
47
+ this._unsubscribe = ctx.signals.on('**', (event) => {
48
48
  this.record({
49
49
  name: event.name,
50
50
  data: event.data,
@@ -12,7 +12,7 @@
12
12
  import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
13
13
  import Badge from 'primevue/badge'
14
14
  import Button from 'primevue/button'
15
- import { ZonesPanel, AuthPanel, EntitiesPanel, ToastsPanel, EntriesPanel } from './panels'
15
+ import { ZonesPanel, AuthPanel, EntitiesPanel, ToastsPanel, EntriesPanel, SignalsPanel } from './panels'
16
16
 
17
17
  const props = defineProps({
18
18
  bridge: { type: Object, required: true },
@@ -658,6 +658,13 @@ function getCollectorColor(name) {
658
658
  @update="notifyBridge"
659
659
  />
660
660
 
661
+ <!-- Signals collector -->
662
+ <SignalsPanel
663
+ v-else-if="currentCollector.name === 'signals' || currentCollector.name === 'SignalCollector'"
664
+ :collector="currentCollector.collector"
665
+ :entries="currentCollector.entries"
666
+ />
667
+
661
668
  <div v-else-if="currentCollector.entries.length === 0" class="debug-empty">
662
669
  <i class="pi pi-inbox" />
663
670
  <span>No entries</span>
@@ -138,6 +138,11 @@ function getCapabilityLabel(cap) {
138
138
  class="pi pi-eye perm-icon-readonly"
139
139
  title="Read-only entity"
140
140
  />
141
+ <i
142
+ v-if="entity.authSensitive"
143
+ class="pi pi-shield perm-icon-auth-sensitive"
144
+ title="Auth-sensitive (auto-invalidates on auth events)"
145
+ />
141
146
  </div>
142
147
  <span class="entity-label">{{ entity.label }}</span>
143
148
  <span v-if="entity.cache.enabled" class="entity-cache" :class="{ 'entity-cache-valid': entity.cache.valid }">
@@ -397,6 +402,10 @@ function getCapabilityLabel(cap) {
397
402
  color: #f59e0b;
398
403
  margin-left: 2px;
399
404
  }
405
+ .perm-icon-auth-sensitive {
406
+ color: #8b5cf6;
407
+ margin-left: 2px;
408
+ }
400
409
  .entity-label {
401
410
  color: #a1a1aa;
402
411
  font-size: 11px;
@@ -46,6 +46,7 @@ async function copyEntry(entry, idx) {
46
46
  :class="{ 'entry-new': entry._isNew }"
47
47
  >
48
48
  <div class="entry-meta">
49
+ <span v-if="entry._isNew" class="entry-new-dot" title="New (unseen)" />
49
50
  <span class="entry-time">{{ formatTime(entry.timestamp) }}</span>
50
51
  <span v-if="entry.name" class="entry-name">{{ entry.name }}</span>
51
52
  </div>
@@ -62,6 +63,7 @@ async function copyEntry(entry, idx) {
62
63
  :class="{ 'entry-new': entry._isNew }"
63
64
  >
64
65
  <div class="entry-header">
66
+ <span v-if="entry._isNew" class="entry-new-dot" title="New (unseen)" />
65
67
  <span class="entry-time">{{ formatTime(entry.timestamp) }}</span>
66
68
  <span v-if="entry.name" class="entry-name">{{ entry.name }}</span>
67
69
  <span v-if="entry.message" class="entry-message">{{ entry.message }}</span>
@@ -98,9 +100,6 @@ async function copyEntry(entry, idx) {
98
100
  .entry-h:last-child {
99
101
  border-right: none;
100
102
  }
101
- .entry-h.entry-new {
102
- border-left: 3px solid #8b5cf6;
103
- }
104
103
 
105
104
  /* Vertical entries (right/fullscreen mode) */
106
105
  .entries-v {
@@ -118,7 +117,7 @@ async function copyEntry(entry, idx) {
118
117
  /* New entry indicator */
119
118
  .entry-new {
120
119
  position: relative;
121
- background: rgba(139, 92, 246, 0.08);
120
+ background: rgba(245, 158, 11, 0.08);
122
121
  }
123
122
  .entry-v.entry-new::before {
124
123
  content: '';
@@ -127,7 +126,20 @@ async function copyEntry(entry, idx) {
127
126
  top: 0;
128
127
  bottom: 0;
129
128
  width: 3px;
130
- background: #8b5cf6;
129
+ background: #f59e0b;
130
+ }
131
+ .entry-h.entry-new {
132
+ border-left: 3px solid #f59e0b;
133
+ }
134
+
135
+ .entry-new-dot {
136
+ display: inline-block;
137
+ width: 6px;
138
+ height: 6px;
139
+ background: #f59e0b;
140
+ border-radius: 50%;
141
+ margin-right: 4px;
142
+ flex-shrink: 0;
131
143
  }
132
144
 
133
145
  /* Entry parts */