qdadm 0.32.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 +2 -1
- package/src/auth/SessionAuthAdapter.js +254 -0
- package/src/auth/index.js +10 -0
- package/src/components/layout/AppLayout.vue +2 -0
- package/src/components/pages/LoginPage.vue +3 -2
- package/src/composables/useFormPageBuilder.js +7 -1
- package/src/composables/useListPageBuilder.js +7 -1
- package/src/debug/Collector.js +2 -1
- package/src/entity/EntityManager.js +1 -1
- package/src/entity/auth/CompositeAuthAdapter.js +2 -2
- package/src/entity/auth/{AuthAdapter.js → EntityAuthAdapter.js} +10 -37
- package/src/entity/auth/PermissiveAdapter.js +4 -6
- package/src/entity/auth/factory.js +7 -7
- package/src/entity/auth/factory.test.js +4 -4
- package/src/entity/auth/index.js +1 -1
- package/src/index.js +3 -0
- package/src/kernel/Kernel.js +18 -18
- package/src/kernel/KernelContext.js +142 -0
- package/src/orchestrator/Orchestrator.js +21 -1
- package/src/orchestrator/useOrchestrator.js +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qdadm",
|
|
3
|
-
"version": "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
|
|
@@ -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
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
|
package/src/debug/Collector.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
* ```
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import {
|
|
29
|
+
import { EntityAuthAdapter } from './EntityAuthAdapter.js'
|
|
30
30
|
import { authFactory } from './factory.js'
|
|
31
31
|
|
|
32
|
-
export class CompositeAuthAdapter extends
|
|
32
|
+
export class CompositeAuthAdapter extends EntityAuthAdapter {
|
|
33
33
|
/**
|
|
34
34
|
* @param {object} config - Composite auth configuration
|
|
35
35
|
* @param {AuthAdapter|string|object} config.default - Default adapter (required)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* EntityAuthAdapter - Interface for entity-level permission checks
|
|
3
3
|
*
|
|
4
|
-
* Applications implement this interface to plug their
|
|
4
|
+
* Applications implement this interface to plug their authorization
|
|
5
5
|
* system into qdadm's EntityManager. The adapter provides two levels of permission checks:
|
|
6
6
|
*
|
|
7
7
|
* 1. **Scopes** (action-level): Can the user perform this action on this entity type?
|
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* 2. **Silos** (record-level): Can the user access this specific record?
|
|
11
11
|
* Example: Can user see invoice #123? (ownership, team membership, etc.)
|
|
12
12
|
*
|
|
13
|
+
* Note: For user session authentication (login/logout), see SessionAuthAdapter.
|
|
14
|
+
*
|
|
13
15
|
* Usage:
|
|
14
16
|
* ```js
|
|
15
|
-
* class
|
|
17
|
+
* class MyEntityAuthAdapter extends EntityAuthAdapter {
|
|
16
18
|
* canPerform(entity, action) {
|
|
17
19
|
* const user = this.getCurrentUser()
|
|
18
20
|
* return user?.permissions?.includes(`${entity}:${action}`)
|
|
@@ -20,7 +22,6 @@
|
|
|
20
22
|
*
|
|
21
23
|
* canAccessRecord(entity, record) {
|
|
22
24
|
* const user = this.getCurrentUser()
|
|
23
|
-
* // Check ownership or team membership
|
|
24
25
|
* return record.owner_id === user?.id || record.team_id === user?.team_id
|
|
25
26
|
* }
|
|
26
27
|
*
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
*
|
|
33
34
|
* @interface
|
|
34
35
|
*/
|
|
35
|
-
export class
|
|
36
|
+
export class EntityAuthAdapter {
|
|
36
37
|
/**
|
|
37
38
|
* SecurityChecker instance for isGranted() delegation
|
|
38
39
|
* @type {import('./SecurityChecker.js').SecurityChecker|null}
|
|
@@ -104,14 +105,11 @@ export class AuthAdapter {
|
|
|
104
105
|
* @returns {boolean} True if user can perform the action on this entity type
|
|
105
106
|
*
|
|
106
107
|
* @example
|
|
107
|
-
* // Check if user can create invoices
|
|
108
108
|
* adapter.canPerform('invoices', 'create') // true/false
|
|
109
|
-
*
|
|
110
|
-
* // Check if user can delete users
|
|
111
109
|
* adapter.canPerform('users', 'delete') // true/false
|
|
112
110
|
*/
|
|
113
111
|
canPerform(entity, action) {
|
|
114
|
-
throw new Error('[
|
|
112
|
+
throw new Error('[EntityAuthAdapter] canPerform() must be implemented by subclass')
|
|
115
113
|
}
|
|
116
114
|
|
|
117
115
|
/**
|
|
@@ -121,52 +119,27 @@ export class AuthAdapter {
|
|
|
121
119
|
* this determines if the user can access a particular record based on
|
|
122
120
|
* ownership, team membership, or other business rules.
|
|
123
121
|
*
|
|
124
|
-
* Called during:
|
|
125
|
-
* - get() operations
|
|
126
|
-
* - update() / patch() operations
|
|
127
|
-
* - delete() operations
|
|
128
|
-
* - list() result filtering (optional)
|
|
129
|
-
*
|
|
130
122
|
* @param {string} entity - Entity name (e.g., 'users', 'invoices')
|
|
131
123
|
* @param {object} record - The full entity record to check access for
|
|
132
124
|
* @returns {boolean} True if user can access this specific record
|
|
133
125
|
*
|
|
134
126
|
* @example
|
|
135
|
-
* // Check if user can access a specific invoice
|
|
136
127
|
* adapter.canAccessRecord('invoices', { id: 123, owner_id: 456, ... })
|
|
137
|
-
*
|
|
138
|
-
* @example
|
|
139
|
-
* // Common implementations:
|
|
140
|
-
* // 1. Ownership: record.owner_id === user.id
|
|
141
|
-
* // 2. Team: record.team_id === user.team_id
|
|
142
|
-
* // 3. Role: user.role === 'admin' (admins see all)
|
|
143
|
-
* // 4. Hierarchical: record.organization_id in user.organizations
|
|
144
128
|
*/
|
|
145
129
|
canAccessRecord(entity, record) {
|
|
146
|
-
throw new Error('[
|
|
130
|
+
throw new Error('[EntityAuthAdapter] canAccessRecord() must be implemented by subclass')
|
|
147
131
|
}
|
|
148
132
|
|
|
149
133
|
/**
|
|
150
134
|
* Get the current authenticated user
|
|
151
135
|
*
|
|
152
136
|
* Returns the user object or null if not authenticated. The user object
|
|
153
|
-
* should contain whatever information is needed for permission checks
|
|
154
|
-
* (id, role, team_id, permissions array, etc.).
|
|
137
|
+
* should contain whatever information is needed for permission checks.
|
|
155
138
|
*
|
|
156
139
|
* @returns {object|null} Current user object or null if not authenticated
|
|
157
|
-
*
|
|
158
|
-
* @example
|
|
159
|
-
* // Typical user object:
|
|
160
|
-
* {
|
|
161
|
-
* id: 123,
|
|
162
|
-
* email: 'user@example.com',
|
|
163
|
-
* role: 'manager',
|
|
164
|
-
* team_id: 456,
|
|
165
|
-
* permissions: ['invoices:create', 'invoices:read', 'users:read']
|
|
166
|
-
* }
|
|
167
140
|
*/
|
|
168
141
|
getCurrentUser() {
|
|
169
|
-
throw new Error('[
|
|
142
|
+
throw new Error('[EntityAuthAdapter] getCurrentUser() must be implemented by subclass')
|
|
170
143
|
}
|
|
171
144
|
}
|
|
172
145
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PermissiveAuthAdapter - Default adapter that allows all operations
|
|
3
3
|
*
|
|
4
|
-
* This adapter is used when no custom
|
|
4
|
+
* This adapter is used when no custom EntityAuthAdapter is provided to EntityManager.
|
|
5
5
|
* It returns `true` for all permission checks, effectively disabling authorization.
|
|
6
6
|
*
|
|
7
7
|
* Use cases:
|
|
@@ -11,18 +11,16 @@
|
|
|
11
11
|
* - Testing environments
|
|
12
12
|
*
|
|
13
13
|
* @example
|
|
14
|
-
* // Explicit usage (rarely needed)
|
|
15
14
|
* const adapter = new PermissiveAuthAdapter()
|
|
16
|
-
*
|
|
17
15
|
* adapter.canPerform('users', 'delete') // true
|
|
18
16
|
* adapter.canAccessRecord('invoices', { id: 123, secret: true }) // true
|
|
19
17
|
* adapter.getCurrentUser() // null
|
|
20
18
|
*
|
|
21
|
-
* @extends
|
|
19
|
+
* @extends EntityAuthAdapter
|
|
22
20
|
*/
|
|
23
|
-
import {
|
|
21
|
+
import { EntityAuthAdapter } from './EntityAuthAdapter.js'
|
|
24
22
|
|
|
25
|
-
export class PermissiveAuthAdapter extends
|
|
23
|
+
export class PermissiveAuthAdapter extends EntityAuthAdapter {
|
|
26
24
|
/**
|
|
27
25
|
* Always allows any action on any entity type
|
|
28
26
|
*
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import {
|
|
30
|
+
import { EntityAuthAdapter } from './EntityAuthAdapter.js'
|
|
31
31
|
import { PermissiveAuthAdapter } from './PermissiveAdapter.js'
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Built-in auth adapter types
|
|
35
35
|
* Extended via context.authTypes for custom adapters
|
|
36
|
-
* @type {Record<string, typeof
|
|
36
|
+
* @type {Record<string, typeof EntityAuthAdapter>}
|
|
37
37
|
*/
|
|
38
38
|
export const authTypes = {
|
|
39
39
|
permissive: PermissiveAuthAdapter
|
|
@@ -65,7 +65,7 @@ export function parseAuthPattern(pattern) {
|
|
|
65
65
|
*
|
|
66
66
|
* @param {object} config - Normalized auth config with `type` property
|
|
67
67
|
* @param {object} context - Context with authTypes registry
|
|
68
|
-
* @returns {
|
|
68
|
+
* @returns {EntityAuthAdapter} Adapter instance
|
|
69
69
|
*/
|
|
70
70
|
export function defaultAuthResolver(config, context = {}) {
|
|
71
71
|
const { type, ...rest } = config
|
|
@@ -109,9 +109,9 @@ function isCompositeConfig(config) {
|
|
|
109
109
|
* - Config object with 'type' → resolve via registry
|
|
110
110
|
* - Config object with 'default' → create CompositeAuthAdapter
|
|
111
111
|
*
|
|
112
|
-
* @param {
|
|
112
|
+
* @param {EntityAuthAdapter | string | object} config - Auth config
|
|
113
113
|
* @param {object} [context={}] - Context with authTypes, authResolver
|
|
114
|
-
* @returns {
|
|
114
|
+
* @returns {EntityAuthAdapter} Adapter instance
|
|
115
115
|
*
|
|
116
116
|
* @example
|
|
117
117
|
* // Instance passthrough (most common, backward compatible)
|
|
@@ -138,8 +138,8 @@ export function authFactory(config, context = {}) {
|
|
|
138
138
|
return new PermissiveAuthAdapter()
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
// Already an
|
|
142
|
-
if (config instanceof
|
|
141
|
+
// Already an EntityAuthAdapter instance → return directly (backward compatible)
|
|
142
|
+
if (config instanceof EntityAuthAdapter) {
|
|
143
143
|
return config
|
|
144
144
|
}
|
|
145
145
|
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* Tests for auth factory and CompositeAuthAdapter
|
|
3
3
|
*/
|
|
4
4
|
import { describe, it, expect } from 'vitest'
|
|
5
|
-
import {
|
|
5
|
+
import { EntityAuthAdapter } from './EntityAuthAdapter.js'
|
|
6
6
|
import { PermissiveAuthAdapter } from './PermissiveAdapter.js'
|
|
7
7
|
import { CompositeAuthAdapter } from './CompositeAuthAdapter.js'
|
|
8
8
|
import { authFactory, parseAuthPattern, authTypes } from './factory.js'
|
|
9
9
|
|
|
10
10
|
// Test adapter for custom types
|
|
11
|
-
class TestAuthAdapter extends
|
|
11
|
+
class TestAuthAdapter extends EntityAuthAdapter {
|
|
12
12
|
constructor(options = {}) {
|
|
13
13
|
super()
|
|
14
14
|
this.options = options
|
|
@@ -19,7 +19,7 @@ class TestAuthAdapter extends AuthAdapter {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
// Adapter that tracks which entity was checked
|
|
22
|
-
class TrackingAdapter extends
|
|
22
|
+
class TrackingAdapter extends EntityAuthAdapter {
|
|
23
23
|
constructor(name) {
|
|
24
24
|
super()
|
|
25
25
|
this.name = name
|
|
@@ -53,7 +53,7 @@ describe('parseAuthPattern', () => {
|
|
|
53
53
|
|
|
54
54
|
describe('authFactory', () => {
|
|
55
55
|
describe('instance passthrough', () => {
|
|
56
|
-
it('returns
|
|
56
|
+
it('returns EntityAuthAdapter instance as-is', () => {
|
|
57
57
|
const adapter = new PermissiveAuthAdapter()
|
|
58
58
|
const result = authFactory(adapter)
|
|
59
59
|
expect(result).toBe(adapter)
|
package/src/entity/auth/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
// Interface
|
|
13
|
-
export {
|
|
13
|
+
export { EntityAuthAdapter, AuthActions } from './EntityAuthAdapter.js'
|
|
14
14
|
|
|
15
15
|
// Role Hierarchy (topological resolution)
|
|
16
16
|
export { RoleHierarchy, createRoleHierarchy } from './RoleHierarchy.js'
|
package/src/index.js
CHANGED
package/src/kernel/Kernel.js
CHANGED
|
@@ -184,21 +184,21 @@ export class Kernel {
|
|
|
184
184
|
this._createHookRegistry()
|
|
185
185
|
this._createZoneRegistry()
|
|
186
186
|
this._createDeferredRegistry()
|
|
187
|
-
// 2.
|
|
187
|
+
// 2. Create orchestrator early (modules need it for ctx.entity())
|
|
188
|
+
this._createOrchestrator()
|
|
189
|
+
// 3. Register auth:ready deferred (if auth configured)
|
|
188
190
|
this._registerAuthDeferred()
|
|
189
|
-
//
|
|
191
|
+
// 4. Initialize legacy modules (can use all services, registers routes)
|
|
190
192
|
this._initModules()
|
|
191
|
-
//
|
|
193
|
+
// 4.5. Load new-style modules (moduleDefs) - synchronous for backward compat
|
|
192
194
|
this._loadModulesSync()
|
|
193
|
-
//
|
|
195
|
+
// 5. Create router (needs routes from modules)
|
|
194
196
|
this._createRouter()
|
|
195
|
-
//
|
|
197
|
+
// 5.5. Setup auth guard (if authAdapter provided)
|
|
196
198
|
this._setupAuthGuard()
|
|
197
|
-
//
|
|
199
|
+
// 6. Setup auth:expired handler (needs router + authAdapter)
|
|
198
200
|
this._setupAuthExpiredHandler()
|
|
199
|
-
//
|
|
200
|
-
this._createOrchestrator()
|
|
201
|
-
// 7. Wire modules that need orchestrator (phase 2)
|
|
201
|
+
// 7. Wire modules that need orchestrator (phase 2 - kernel:ready signal)
|
|
202
202
|
this._wireModules()
|
|
203
203
|
// 8. Create EventRouter (needs signals + orchestrator)
|
|
204
204
|
this._createEventRouter()
|
|
@@ -229,21 +229,21 @@ export class Kernel {
|
|
|
229
229
|
this._createHookRegistry()
|
|
230
230
|
this._createZoneRegistry()
|
|
231
231
|
this._createDeferredRegistry()
|
|
232
|
-
// 2.
|
|
232
|
+
// 2. Create orchestrator early (modules need it for ctx.entity())
|
|
233
|
+
this._createOrchestrator()
|
|
234
|
+
// 3. Register auth:ready deferred (if auth configured)
|
|
233
235
|
this._registerAuthDeferred()
|
|
234
|
-
//
|
|
236
|
+
// 4. Initialize legacy modules (can use all services, registers routes)
|
|
235
237
|
this._initModules()
|
|
236
|
-
//
|
|
238
|
+
// 4.5. Load new-style modules (moduleDefs) - async version
|
|
237
239
|
await this._loadModules()
|
|
238
|
-
//
|
|
240
|
+
// 5. Create router (needs routes from modules)
|
|
239
241
|
this._createRouter()
|
|
240
|
-
//
|
|
242
|
+
// 5.5. Setup auth guard (if authAdapter provided)
|
|
241
243
|
this._setupAuthGuard()
|
|
242
|
-
//
|
|
244
|
+
// 6. Setup auth:expired handler (needs router + authAdapter)
|
|
243
245
|
this._setupAuthExpiredHandler()
|
|
244
|
-
//
|
|
245
|
-
this._createOrchestrator()
|
|
246
|
-
// 7. Wire modules that need orchestrator (phase 2)
|
|
246
|
+
// 7. Wire modules that need orchestrator (phase 2 - kernel:ready signal)
|
|
247
247
|
await this._wireModulesAsync()
|
|
248
248
|
// 8. Create EventRouter (needs signals + orchestrator)
|
|
249
249
|
this._createEventRouter()
|
|
@@ -222,6 +222,148 @@ export class KernelContext {
|
|
|
222
222
|
return this
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Register standard CRUD routes with naming conventions
|
|
227
|
+
*
|
|
228
|
+
* Generates routes following qdadm conventions:
|
|
229
|
+
* - Entity 'books' → route prefix 'book'
|
|
230
|
+
* - List: /books → name 'book'
|
|
231
|
+
* - Create: /books/create → name 'book-create'
|
|
232
|
+
* - Edit: /books/:id/edit → name 'book-edit'
|
|
233
|
+
*
|
|
234
|
+
* Also registers route family and optional nav item.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} entity - Entity name (plural, e.g., 'books', 'users')
|
|
237
|
+
* @param {object} pages - Page components (lazy imports)
|
|
238
|
+
* @param {Function} pages.list - List page: () => import('./pages/List.vue')
|
|
239
|
+
* @param {Function} [pages.form] - Single form for create+edit (recommended)
|
|
240
|
+
* @param {Function} [pages.create] - Separate create page (alternative to form)
|
|
241
|
+
* @param {Function} [pages.edit] - Separate edit page (alternative to form)
|
|
242
|
+
* @param {object} [options={}] - Additional options
|
|
243
|
+
* @param {object} [options.nav] - Navigation config
|
|
244
|
+
* @param {string} options.nav.section - Nav section (e.g., 'Library')
|
|
245
|
+
* @param {string} [options.nav.icon] - Icon class (e.g., 'pi pi-book')
|
|
246
|
+
* @param {string} [options.nav.label] - Display label (default: capitalized entity)
|
|
247
|
+
* @param {string} [options.routePrefix] - Override route prefix (default: singularized entity)
|
|
248
|
+
* @returns {this}
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* // Minimal - list only (read-only entity)
|
|
252
|
+
* ctx.crud('countries', { list: () => import('./pages/CountriesList.vue') })
|
|
253
|
+
*
|
|
254
|
+
* @example
|
|
255
|
+
* // Single form pattern (recommended)
|
|
256
|
+
* ctx.crud('books', {
|
|
257
|
+
* list: () => import('./pages/BookList.vue'),
|
|
258
|
+
* form: () => import('./pages/BookForm.vue')
|
|
259
|
+
* }, {
|
|
260
|
+
* nav: { section: 'Library', icon: 'pi pi-book' }
|
|
261
|
+
* })
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* // Separate create/edit pages
|
|
265
|
+
* ctx.crud('users', {
|
|
266
|
+
* list: () => import('./pages/UserList.vue'),
|
|
267
|
+
* create: () => import('./pages/UserCreate.vue'),
|
|
268
|
+
* edit: () => import('./pages/UserEdit.vue')
|
|
269
|
+
* }, {
|
|
270
|
+
* nav: { section: 'Admin', icon: 'pi pi-users', label: 'Users' }
|
|
271
|
+
* })
|
|
272
|
+
*/
|
|
273
|
+
crud(entity, pages, options = {}) {
|
|
274
|
+
// Derive route prefix: 'books' → 'book', 'countries' → 'country'
|
|
275
|
+
const routePrefix = options.routePrefix || this._singularize(entity)
|
|
276
|
+
|
|
277
|
+
// Build routes array
|
|
278
|
+
const routes = []
|
|
279
|
+
|
|
280
|
+
// List route (always required)
|
|
281
|
+
if (pages.list) {
|
|
282
|
+
routes.push({
|
|
283
|
+
path: '',
|
|
284
|
+
name: routePrefix,
|
|
285
|
+
component: pages.list,
|
|
286
|
+
meta: { layout: 'list' }
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Form routes - single form or separate create/edit
|
|
291
|
+
if (pages.form) {
|
|
292
|
+
// Single form pattern (recommended)
|
|
293
|
+
routes.push({
|
|
294
|
+
path: 'create',
|
|
295
|
+
name: `${routePrefix}-create`,
|
|
296
|
+
component: pages.form
|
|
297
|
+
})
|
|
298
|
+
routes.push({
|
|
299
|
+
path: ':id/edit',
|
|
300
|
+
name: `${routePrefix}-edit`,
|
|
301
|
+
component: pages.form
|
|
302
|
+
})
|
|
303
|
+
} else {
|
|
304
|
+
// Separate create/edit pages
|
|
305
|
+
if (pages.create) {
|
|
306
|
+
routes.push({
|
|
307
|
+
path: 'create',
|
|
308
|
+
name: `${routePrefix}-create`,
|
|
309
|
+
component: pages.create
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
if (pages.edit) {
|
|
313
|
+
routes.push({
|
|
314
|
+
path: ':id/edit',
|
|
315
|
+
name: `${routePrefix}-edit`,
|
|
316
|
+
component: pages.edit
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Register routes with entity binding
|
|
322
|
+
this.routes(entity, routes, { entity })
|
|
323
|
+
|
|
324
|
+
// Register route family for active state detection
|
|
325
|
+
this.routeFamily(routePrefix, [`${routePrefix}-`])
|
|
326
|
+
|
|
327
|
+
// Register nav item if provided
|
|
328
|
+
if (options.nav) {
|
|
329
|
+
const label = options.nav.label || this._capitalize(entity)
|
|
330
|
+
this.navItem({
|
|
331
|
+
section: options.nav.section,
|
|
332
|
+
route: routePrefix,
|
|
333
|
+
icon: options.nav.icon,
|
|
334
|
+
label,
|
|
335
|
+
entity
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return this
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Singularize a plural word (simple English rules)
|
|
344
|
+
* @param {string} plural - Plural word
|
|
345
|
+
* @returns {string} Singular form
|
|
346
|
+
* @private
|
|
347
|
+
*/
|
|
348
|
+
_singularize(plural) {
|
|
349
|
+
if (plural.endsWith('ies')) return plural.slice(0, -3) + 'y'
|
|
350
|
+
if (plural.endsWith('ses') || plural.endsWith('xes') || plural.endsWith('zes')) {
|
|
351
|
+
return plural.slice(0, -2)
|
|
352
|
+
}
|
|
353
|
+
if (plural.endsWith('s') && !plural.endsWith('ss')) return plural.slice(0, -1)
|
|
354
|
+
return plural
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Capitalize first letter
|
|
359
|
+
* @param {string} str - String to capitalize
|
|
360
|
+
* @returns {string} Capitalized string
|
|
361
|
+
* @private
|
|
362
|
+
*/
|
|
363
|
+
_capitalize(str) {
|
|
364
|
+
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
365
|
+
}
|
|
366
|
+
|
|
225
367
|
/**
|
|
226
368
|
* Define a zone for extensible UI composition
|
|
227
369
|
*
|
|
@@ -200,7 +200,27 @@ export class Orchestrator {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
// Build helpful error message
|
|
204
|
+
const registered = this.getRegisteredNames()
|
|
205
|
+
const hint = registered.length > 0
|
|
206
|
+
? `\nRegistered entities: ${registered.join(', ')}\n`
|
|
207
|
+
: '\nNo entities registered yet.\n'
|
|
208
|
+
|
|
209
|
+
const suggestion = `
|
|
210
|
+
Possible causes:
|
|
211
|
+
1. Entity "${name}" not declared in any module (ctx.entity('${name}', {...}))
|
|
212
|
+
2. Module not loaded (check moduleDefs in config/modules.js)
|
|
213
|
+
3. Typo in entity name
|
|
214
|
+
|
|
215
|
+
Example fix in your module:
|
|
216
|
+
ctx.entity('${name}', {
|
|
217
|
+
name: '${name}',
|
|
218
|
+
labelField: 'name',
|
|
219
|
+
fields: { ... },
|
|
220
|
+
storage: yourStorage
|
|
221
|
+
})`
|
|
222
|
+
|
|
223
|
+
throw new Error(`[Orchestrator] No manager for entity "${name}"${hint}${suggestion}`)
|
|
204
224
|
}
|
|
205
225
|
|
|
206
226
|
/**
|
|
@@ -18,7 +18,13 @@ export function useOrchestrator() {
|
|
|
18
18
|
const orchestrator = inject('qdadmOrchestrator')
|
|
19
19
|
|
|
20
20
|
if (!orchestrator) {
|
|
21
|
-
throw new Error(
|
|
21
|
+
throw new Error(
|
|
22
|
+
'[qdadm] Orchestrator not provided.\n' +
|
|
23
|
+
'Possible causes:\n' +
|
|
24
|
+
'1. Kernel not initialized - ensure createKernel().createApp() is called before mounting\n' +
|
|
25
|
+
'2. Component used outside of qdadm app context\n' +
|
|
26
|
+
'3. Missing entityFactory in Kernel options'
|
|
27
|
+
)
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/**
|