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 +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/debug/EntitiesCollector.js +6 -3
- package/src/debug/SignalCollector.js +4 -4
- package/src/debug/components/DebugBar.vue +8 -1
- package/src/debug/components/panels/EntitiesPanel.vue +9 -0
- package/src/debug/components/panels/EntriesPanel.vue +17 -5
- package/src/debug/components/panels/SignalsPanel.vue +335 -0
- package/src/debug/components/panels/index.js +1 -0
- package/src/entity/EntityManager.js +69 -17
- 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/EventRouter.js +7 -4
- 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
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* SignalsPanel - Debug panel for signals with pattern filter
|
|
4
|
+
*
|
|
5
|
+
* Supports QuarKernel wildcard patterns:
|
|
6
|
+
* - auth:** → all auth signals (auth:login, auth:impersonate:start)
|
|
7
|
+
* - cache:** → all cache signals
|
|
8
|
+
* - *:created → all creation signals
|
|
9
|
+
* - ** → all signals (default)
|
|
10
|
+
*/
|
|
11
|
+
import { ref, computed, watch } from 'vue'
|
|
12
|
+
import InputText from 'primevue/inputtext'
|
|
13
|
+
import ObjectTree from '../ObjectTree.vue'
|
|
14
|
+
|
|
15
|
+
const props = defineProps({
|
|
16
|
+
collector: { type: Object, required: true },
|
|
17
|
+
entries: { type: Array, required: true }
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// Filter state - persisted in localStorage
|
|
21
|
+
const STORAGE_KEY = 'qdadm-signals-filter'
|
|
22
|
+
const STORAGE_KEY_MAX = 'qdadm-signals-max'
|
|
23
|
+
const filterPattern = ref(localStorage.getItem(STORAGE_KEY) || '')
|
|
24
|
+
const maxSignals = ref(parseInt(localStorage.getItem(STORAGE_KEY_MAX)) || 50)
|
|
25
|
+
|
|
26
|
+
watch(filterPattern, (val) => {
|
|
27
|
+
localStorage.setItem(STORAGE_KEY, val)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
watch(maxSignals, (val) => {
|
|
31
|
+
localStorage.setItem(STORAGE_KEY_MAX, String(val))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Convert wildcard pattern to regex
|
|
35
|
+
function wildcardToRegex(pattern) {
|
|
36
|
+
if (!pattern || pattern === '**') return null // No filter
|
|
37
|
+
|
|
38
|
+
// Escape regex special chars except * and :
|
|
39
|
+
let regex = pattern
|
|
40
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
41
|
+
// ** matches anything (including colons)
|
|
42
|
+
.replace(/\*\*/g, '.*')
|
|
43
|
+
// * matches anything except colon
|
|
44
|
+
.replace(/\*/g, '[^:]*')
|
|
45
|
+
|
|
46
|
+
return new RegExp(`^${regex}$`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const filterRegex = computed(() => wildcardToRegex(filterPattern.value.trim()))
|
|
50
|
+
|
|
51
|
+
// Apply filter, max limit, and reverse for top-down (newest first)
|
|
52
|
+
const filteredEntries = computed(() => {
|
|
53
|
+
let result = props.entries
|
|
54
|
+
if (filterRegex.value) {
|
|
55
|
+
result = result.filter(e => filterRegex.value.test(e.name))
|
|
56
|
+
}
|
|
57
|
+
// Apply max limit (slice from end to keep newest)
|
|
58
|
+
if (maxSignals.value > 0 && result.length > maxSignals.value) {
|
|
59
|
+
result = result.slice(-maxSignals.value)
|
|
60
|
+
}
|
|
61
|
+
// Reverse for top-down display (newest first)
|
|
62
|
+
return [...result].reverse()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const filterStats = computed(() => {
|
|
66
|
+
const total = props.entries.length
|
|
67
|
+
const shown = filteredEntries.value.length
|
|
68
|
+
return total !== shown ? `${shown}/${total}` : `${total}`
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Preset filters
|
|
72
|
+
const presets = [
|
|
73
|
+
{ label: 'All', pattern: '' },
|
|
74
|
+
{ label: 'data', pattern: 'entity:data-invalidate' },
|
|
75
|
+
{ label: 'datalayer', pattern: 'entity:datalayer-invalidate' },
|
|
76
|
+
{ label: 'auth', pattern: 'auth:**' },
|
|
77
|
+
{ label: 'entity', pattern: 'entity:**' },
|
|
78
|
+
{ label: 'toast', pattern: 'toast:**' }
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
function applyPreset(pattern) {
|
|
82
|
+
filterPattern.value = pattern
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatTime(ts) {
|
|
86
|
+
const d = new Date(ts)
|
|
87
|
+
return d.toLocaleTimeString('en-US', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Extract domain from signal name (first segment)
|
|
91
|
+
function getDomain(name) {
|
|
92
|
+
return name.split(':')[0]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const domainColors = {
|
|
96
|
+
auth: '#10b981',
|
|
97
|
+
cache: '#f59e0b',
|
|
98
|
+
entity: '#3b82f6',
|
|
99
|
+
toast: '#8b5cf6',
|
|
100
|
+
route: '#06b6d4',
|
|
101
|
+
error: '#ef4444'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getDomainColor(name) {
|
|
105
|
+
const domain = getDomain(name)
|
|
106
|
+
return domainColors[domain] || '#6b7280'
|
|
107
|
+
}
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<template>
|
|
111
|
+
<div class="signals-panel">
|
|
112
|
+
<!-- Filter bar -->
|
|
113
|
+
<div class="signals-filter">
|
|
114
|
+
<div class="signals-filter-input">
|
|
115
|
+
<i class="pi pi-filter" />
|
|
116
|
+
<InputText
|
|
117
|
+
v-model="filterPattern"
|
|
118
|
+
placeholder="Filter: auth:** cache:** *:created"
|
|
119
|
+
class="filter-input"
|
|
120
|
+
/>
|
|
121
|
+
<span class="signals-count">{{ filterStats }}</span>
|
|
122
|
+
<span class="signals-max-label">max</span>
|
|
123
|
+
<input
|
|
124
|
+
v-model.number="maxSignals"
|
|
125
|
+
type="number"
|
|
126
|
+
min="10"
|
|
127
|
+
max="500"
|
|
128
|
+
class="max-input"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<div class="signals-presets">
|
|
132
|
+
<button
|
|
133
|
+
v-for="p in presets"
|
|
134
|
+
:key="p.pattern"
|
|
135
|
+
class="preset-btn"
|
|
136
|
+
:class="{ 'preset-active': filterPattern === p.pattern }"
|
|
137
|
+
@click="applyPreset(p.pattern)"
|
|
138
|
+
>
|
|
139
|
+
{{ p.label }}
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<!-- Entries list -->
|
|
145
|
+
<div class="signals-list">
|
|
146
|
+
<div v-if="filteredEntries.length === 0" class="signals-empty">
|
|
147
|
+
<i class="pi pi-inbox" />
|
|
148
|
+
<span>{{ entries.length === 0 ? 'No signals' : 'No matching signals' }}</span>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div
|
|
152
|
+
v-for="(entry, idx) in filteredEntries"
|
|
153
|
+
:key="idx"
|
|
154
|
+
class="signal-entry"
|
|
155
|
+
:class="{ 'signal-new': entry._isNew }"
|
|
156
|
+
>
|
|
157
|
+
<div class="signal-header">
|
|
158
|
+
<span v-if="entry._isNew" class="signal-new-dot" title="New (unseen)" />
|
|
159
|
+
<span class="signal-time">{{ formatTime(entry.timestamp) }}</span>
|
|
160
|
+
<span class="signal-name" :style="{ color: getDomainColor(entry.name) }">
|
|
161
|
+
{{ entry.name }}
|
|
162
|
+
</span>
|
|
163
|
+
</div>
|
|
164
|
+
<div v-if="entry.data && Object.keys(entry.data).length > 0" class="signal-data">
|
|
165
|
+
<ObjectTree :data="entry.data" :expanded="false" />
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</template>
|
|
171
|
+
|
|
172
|
+
<style scoped>
|
|
173
|
+
.signals-panel {
|
|
174
|
+
display: flex;
|
|
175
|
+
flex-direction: column;
|
|
176
|
+
height: 100%;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.signals-filter {
|
|
180
|
+
padding: 8px 12px;
|
|
181
|
+
background: #27272a;
|
|
182
|
+
border-bottom: 1px solid #3f3f46;
|
|
183
|
+
display: flex;
|
|
184
|
+
flex-direction: column;
|
|
185
|
+
gap: 6px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.signals-filter-input {
|
|
189
|
+
display: flex;
|
|
190
|
+
align-items: center;
|
|
191
|
+
gap: 8px;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.signals-filter-input .pi {
|
|
195
|
+
color: #71717a;
|
|
196
|
+
font-size: 12px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.filter-input {
|
|
200
|
+
flex: 1;
|
|
201
|
+
font-size: 12px;
|
|
202
|
+
padding: 4px 8px;
|
|
203
|
+
background: #18181b;
|
|
204
|
+
border: 1px solid #3f3f46;
|
|
205
|
+
border-radius: 4px;
|
|
206
|
+
color: #f4f4f5;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.filter-input:focus {
|
|
210
|
+
border-color: #8b5cf6;
|
|
211
|
+
outline: none;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.signals-count {
|
|
215
|
+
font-size: 11px;
|
|
216
|
+
color: #71717a;
|
|
217
|
+
min-width: 40px;
|
|
218
|
+
text-align: right;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.signals-max-label {
|
|
222
|
+
font-size: 10px;
|
|
223
|
+
color: #52525b;
|
|
224
|
+
margin-left: 8px;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.max-input {
|
|
228
|
+
width: 50px;
|
|
229
|
+
font-size: 11px;
|
|
230
|
+
padding: 2px 4px;
|
|
231
|
+
background: #18181b;
|
|
232
|
+
border: 1px solid #3f3f46;
|
|
233
|
+
border-radius: 3px;
|
|
234
|
+
color: #a1a1aa;
|
|
235
|
+
text-align: center;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.max-input:focus {
|
|
239
|
+
border-color: #8b5cf6;
|
|
240
|
+
outline: none;
|
|
241
|
+
color: #f4f4f5;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.signals-presets {
|
|
245
|
+
display: flex;
|
|
246
|
+
gap: 4px;
|
|
247
|
+
flex-wrap: wrap;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.preset-btn {
|
|
251
|
+
padding: 2px 8px;
|
|
252
|
+
font-size: 10px;
|
|
253
|
+
background: #3f3f46;
|
|
254
|
+
border: none;
|
|
255
|
+
border-radius: 3px;
|
|
256
|
+
color: #a1a1aa;
|
|
257
|
+
cursor: pointer;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.preset-btn:hover {
|
|
261
|
+
background: #52525b;
|
|
262
|
+
color: #f4f4f5;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.preset-active {
|
|
266
|
+
background: #8b5cf6;
|
|
267
|
+
color: white;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.signals-list {
|
|
271
|
+
flex: 1;
|
|
272
|
+
overflow-y: auto;
|
|
273
|
+
padding: 4px;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.signals-empty {
|
|
277
|
+
display: flex;
|
|
278
|
+
flex-direction: column;
|
|
279
|
+
align-items: center;
|
|
280
|
+
justify-content: center;
|
|
281
|
+
height: 100%;
|
|
282
|
+
color: #71717a;
|
|
283
|
+
gap: 8px;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.signal-entry {
|
|
287
|
+
padding: 6px 8px;
|
|
288
|
+
margin-bottom: 2px;
|
|
289
|
+
background: #27272a;
|
|
290
|
+
border-radius: 4px;
|
|
291
|
+
font-size: 12px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.signal-entry:hover {
|
|
295
|
+
background: #3f3f46;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.signal-entry.signal-new {
|
|
299
|
+
border-left: 2px solid #f59e0b;
|
|
300
|
+
padding-left: 6px;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.signal-new-dot {
|
|
304
|
+
display: inline-block;
|
|
305
|
+
width: 6px;
|
|
306
|
+
height: 6px;
|
|
307
|
+
background: #f59e0b;
|
|
308
|
+
border-radius: 50%;
|
|
309
|
+
margin-right: 4px;
|
|
310
|
+
flex-shrink: 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.signal-header {
|
|
314
|
+
display: flex;
|
|
315
|
+
align-items: center;
|
|
316
|
+
gap: 8px;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.signal-time {
|
|
320
|
+
font-size: 10px;
|
|
321
|
+
color: #71717a;
|
|
322
|
+
font-family: monospace;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.signal-name {
|
|
326
|
+
font-weight: 500;
|
|
327
|
+
font-family: monospace;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.signal-data {
|
|
331
|
+
margin-top: 4px;
|
|
332
|
+
padding-left: 12px;
|
|
333
|
+
font-size: 11px;
|
|
334
|
+
}
|
|
335
|
+
</style>
|
|
@@ -6,3 +6,4 @@ export { default as AuthPanel } from './AuthPanel.vue'
|
|
|
6
6
|
export { default as EntitiesPanel } from './EntitiesPanel.vue'
|
|
7
7
|
export { default as ToastsPanel } from './ToastsPanel.vue'
|
|
8
8
|
export { default as EntriesPanel } from './EntriesPanel.vue'
|
|
9
|
+
export { default as SignalsPanel } from './SignalsPanel.vue'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PermissiveAuthAdapter } from './auth/PermissiveAdapter.js'
|
|
2
|
-
import { AuthActions } from './auth/
|
|
2
|
+
import { AuthActions } from './auth/EntityAuthAdapter.js'
|
|
3
3
|
import { QueryExecutor } from '../query/QueryExecutor.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -59,6 +59,7 @@ export class EntityManager {
|
|
|
59
59
|
localFilterThreshold = null, // Items threshold to switch to local filtering (null = use default)
|
|
60
60
|
readOnly = false, // If true, canCreate/canUpdate/canDelete return false
|
|
61
61
|
warmup = true, // If true, cache is preloaded at boot via DeferredRegistry
|
|
62
|
+
authSensitive = false, // If true, auto-invalidate datalayer on auth events
|
|
62
63
|
// Scope control
|
|
63
64
|
scopeWhitelist = null, // Array of scopes/modules that can bypass restrictions
|
|
64
65
|
// Relations
|
|
@@ -85,6 +86,7 @@ export class EntityManager {
|
|
|
85
86
|
this.localFilterThreshold = localFilterThreshold
|
|
86
87
|
this._readOnly = readOnly
|
|
87
88
|
this._warmup = warmup
|
|
89
|
+
this._authSensitive = authSensitive
|
|
88
90
|
|
|
89
91
|
// Scope control
|
|
90
92
|
this._scopeWhitelist = scopeWhitelist
|
|
@@ -174,6 +176,25 @@ export class EntityManager {
|
|
|
174
176
|
this._signals.emitEntity(this.name, action, data)
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Emit entity:data-invalidate signal for client cache invalidation
|
|
181
|
+
*
|
|
182
|
+
* This is a unified signal for clients to know when entity data has changed.
|
|
183
|
+
* Clients can listen to `entity:data-invalidate` to refresh their views.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} action - 'created', 'updated', 'deleted'
|
|
186
|
+
* @param {string|number} id - The affected record ID
|
|
187
|
+
* @private
|
|
188
|
+
*/
|
|
189
|
+
_emitDataInvalidate(action, id) {
|
|
190
|
+
if (!this._signals) return
|
|
191
|
+
this._signals.emit('entity:data-invalidate', {
|
|
192
|
+
entity: this.name,
|
|
193
|
+
action,
|
|
194
|
+
id
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
177
198
|
// ============ LIFECYCLE HOOKS ============
|
|
178
199
|
|
|
179
200
|
/**
|
|
@@ -860,6 +881,7 @@ export class EntityManager {
|
|
|
860
881
|
manager: this.name,
|
|
861
882
|
id: result?.[this.idField]
|
|
862
883
|
})
|
|
884
|
+
this._emitDataInvalidate('created', result?.[this.idField])
|
|
863
885
|
return result
|
|
864
886
|
}
|
|
865
887
|
throw new Error(`[EntityManager:${this.name}] create() not implemented`)
|
|
@@ -896,6 +918,7 @@ export class EntityManager {
|
|
|
896
918
|
manager: this.name,
|
|
897
919
|
id
|
|
898
920
|
})
|
|
921
|
+
this._emitDataInvalidate('updated', id)
|
|
899
922
|
return result
|
|
900
923
|
}
|
|
901
924
|
throw new Error(`[EntityManager:${this.name}] update() not implemented`)
|
|
@@ -932,6 +955,7 @@ export class EntityManager {
|
|
|
932
955
|
manager: this.name,
|
|
933
956
|
id
|
|
934
957
|
})
|
|
958
|
+
this._emitDataInvalidate('updated', id)
|
|
935
959
|
return result
|
|
936
960
|
}
|
|
937
961
|
throw new Error(`[EntityManager:${this.name}] patch() not implemented`)
|
|
@@ -959,6 +983,7 @@ export class EntityManager {
|
|
|
959
983
|
manager: this.name,
|
|
960
984
|
id
|
|
961
985
|
})
|
|
986
|
+
this._emitDataInvalidate('deleted', id)
|
|
962
987
|
return result
|
|
963
988
|
}
|
|
964
989
|
throw new Error(`[EntityManager:${this.name}] delete() not implemented`)
|
|
@@ -1143,11 +1168,12 @@ export class EntityManager {
|
|
|
1143
1168
|
|
|
1144
1169
|
const cleanups = []
|
|
1145
1170
|
|
|
1146
|
-
// Listen for parent
|
|
1171
|
+
// Listen for parent data changes (if parents defined)
|
|
1172
|
+
// When a parent entity's data changes, clear search cache to re-resolve parent fields
|
|
1147
1173
|
if (this._parents && Object.keys(this._parents).length > 0) {
|
|
1148
1174
|
const parentEntities = Object.values(this._parents).map(p => p.entity)
|
|
1149
1175
|
cleanups.push(
|
|
1150
|
-
this._signals.on('
|
|
1176
|
+
this._signals.on('entity:data-invalidate', ({ entity }) => {
|
|
1151
1177
|
if (parentEntities.includes(entity)) {
|
|
1152
1178
|
this._clearSearchCache()
|
|
1153
1179
|
}
|
|
@@ -1155,12 +1181,28 @@ export class EntityManager {
|
|
|
1155
1181
|
)
|
|
1156
1182
|
}
|
|
1157
1183
|
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1184
|
+
// Design choice: EntityManager-centric architecture
|
|
1185
|
+
// Storages stay simple (pure data access), EntityManager owns invalidation logic.
|
|
1186
|
+
//
|
|
1187
|
+
// Signal: entity:datalayer-invalidate { entity, actuator? }
|
|
1188
|
+
// - entity: target entity name, '*' for all, or null/undefined for all
|
|
1189
|
+
// - actuator: source of the signal ('auth' for auth events, undefined for manual)
|
|
1190
|
+
//
|
|
1191
|
+
// Routing is handled by EventRouter (configured at Kernel level):
|
|
1192
|
+
// 'auth:**' → { signal: 'entity:datalayer-invalidate', transform: () => ({ actuator: 'auth' }) }
|
|
1193
|
+
//
|
|
1194
|
+
// Only authSensitive entities react to actuator='auth' signals.
|
|
1195
|
+
// All entities react to manual signals (no actuator).
|
|
1196
|
+
|
|
1161
1197
|
cleanups.push(
|
|
1162
|
-
this._signals.on(
|
|
1163
|
-
|
|
1198
|
+
this._signals.on('entity:datalayer-invalidate', ({ entity, actuator } = {}) => {
|
|
1199
|
+
// Auth-triggered: only react if authSensitive
|
|
1200
|
+
if (actuator === 'auth' && !this._authSensitive) return
|
|
1201
|
+
|
|
1202
|
+
// Check entity match (global or targeted)
|
|
1203
|
+
if (!entity || entity === '*' || entity === this.name) {
|
|
1204
|
+
this.invalidateDataLayer()
|
|
1205
|
+
}
|
|
1164
1206
|
})
|
|
1165
1207
|
)
|
|
1166
1208
|
|
|
@@ -1216,24 +1258,34 @@ export class EntityManager {
|
|
|
1216
1258
|
}
|
|
1217
1259
|
|
|
1218
1260
|
/**
|
|
1219
|
-
* Invalidate the cache (
|
|
1261
|
+
* Invalidate the cache, forcing next list() to refetch
|
|
1220
1262
|
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1263
|
+
* Note: entity:data-invalidate signal is emitted by CRUD methods,
|
|
1264
|
+
* not here (to include action and id context).
|
|
1223
1265
|
*/
|
|
1224
1266
|
invalidateCache() {
|
|
1225
|
-
const wasValid = this._cache.valid
|
|
1226
|
-
|
|
1227
1267
|
this._cache.valid = false
|
|
1228
1268
|
this._cache.items = []
|
|
1229
1269
|
this._cache.total = 0
|
|
1230
1270
|
this._cache.loadedAt = null
|
|
1231
1271
|
this._cacheLoading = null
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Invalidate the entire data layer (cache + storage state)
|
|
1276
|
+
*
|
|
1277
|
+
* Called when external context changes (auth, impersonation, etc.)
|
|
1278
|
+
* that may affect what data the storage returns.
|
|
1279
|
+
*
|
|
1280
|
+
* - Clears EntityManager cache
|
|
1281
|
+
* - Calls storage.reset() if available (for storages with internal state)
|
|
1282
|
+
*/
|
|
1283
|
+
invalidateDataLayer() {
|
|
1284
|
+
this.invalidateCache()
|
|
1232
1285
|
|
|
1233
|
-
//
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
this._signals.emit('cache:entity:invalidated', { entity: this.name })
|
|
1286
|
+
// Reset storage if it supports it (e.g., clear auth tokens, cached responses)
|
|
1287
|
+
if (typeof this.storage?.reset === 'function') {
|
|
1288
|
+
this.storage.reset()
|
|
1237
1289
|
}
|
|
1238
1290
|
}
|
|
1239
1291
|
|
|
@@ -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
|
*
|