qdadm 0.31.0 → 0.34.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.34.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
 
@@ -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
@@ -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
 
@@ -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
  /**
@@ -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
  /**
@@ -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
  }