webspresso 0.0.88 → 0.0.90
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/README.md +92 -45
- package/core/auth/manager.js +36 -26
- package/core/auth/middleware.js +12 -2
- package/core/orm/eager-loader.js +64 -70
- package/core/orm/events.js +19 -0
- package/core/orm/model.js +8 -2
- package/core/orm/repository.js +54 -22
- package/core/orm/schema-helpers.js +31 -2
- package/core/orm/scopes.js +2 -1
- package/package.json +1 -1
- package/plugins/admin-panel/app.js +10 -0
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
- package/plugins/admin-panel/client/parts/08-record-list.js +10 -10
- package/plugins/admin-panel/index.js +41 -1
- package/plugins/admin-panel/modules/admin-users.js +770 -0
- package/plugins/content/client/inline-edit.js +41 -17
- package/plugins/cors.js +12 -8
- package/plugins/csrf/index.js +8 -8
- package/src/file-router.js +31 -14
- package/src/helpers.js +4 -3
- package/src/plugin-manager.js +10 -3
- package/src/server.js +184 -18
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
package/core/orm/repository.js
CHANGED
|
@@ -28,6 +28,7 @@ const { getJsonColumns, serializeJsonFields, deserializeJsonFields } = require('
|
|
|
28
28
|
function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
29
29
|
const scopeContext = initialContext || createScopeContext();
|
|
30
30
|
const jsonColumns = getJsonColumns(model);
|
|
31
|
+
const hasJson = jsonColumns.size > 0;
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Get base query builder
|
|
@@ -47,12 +48,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
47
48
|
*/
|
|
48
49
|
async function findById(id, options = {}, emitEvents = true) {
|
|
49
50
|
const { with: withs = [], select } = options;
|
|
50
|
-
const
|
|
51
|
-
|
|
51
|
+
const shouldEmit = emitEvents && (
|
|
52
|
+
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
53
|
+
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
54
|
+
);
|
|
55
|
+
const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
|
|
52
56
|
|
|
53
57
|
async function loadFromDb() {
|
|
54
|
-
if (
|
|
55
|
-
await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND,
|
|
58
|
+
if (shouldEmit) {
|
|
59
|
+
await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, { [model.primaryKey]: id }, ctx);
|
|
56
60
|
if (ctx.isCancelled) {
|
|
57
61
|
throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
|
|
58
62
|
}
|
|
@@ -70,13 +74,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
70
74
|
return null;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
|
|
77
|
+
if (hasJson) {
|
|
78
|
+
deserializeJsonFields(record, jsonColumns);
|
|
79
|
+
}
|
|
74
80
|
|
|
75
81
|
if (withs.length > 0) {
|
|
76
82
|
await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
if (
|
|
85
|
+
if (shouldEmit) {
|
|
80
86
|
ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
|
|
81
87
|
}
|
|
82
88
|
|
|
@@ -113,7 +119,11 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
113
119
|
*/
|
|
114
120
|
async function findOne(conditions, options = {}) {
|
|
115
121
|
const { with: withs = [], select } = options;
|
|
116
|
-
const
|
|
122
|
+
const shouldEmit = (
|
|
123
|
+
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
124
|
+
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
125
|
+
);
|
|
126
|
+
const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
|
|
117
127
|
|
|
118
128
|
const condKeys = Object.keys(conditions);
|
|
119
129
|
const isPkOnly =
|
|
@@ -122,9 +132,11 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
122
132
|
condKeys[0] === model.primaryKey;
|
|
123
133
|
|
|
124
134
|
async function loadFromDb() {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
if (shouldEmit) {
|
|
136
|
+
await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, conditions, ctx);
|
|
137
|
+
if (ctx.isCancelled) {
|
|
138
|
+
throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
|
|
139
|
+
}
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
let qb = baseQuery();
|
|
@@ -143,13 +155,17 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
143
155
|
return null;
|
|
144
156
|
}
|
|
145
157
|
|
|
146
|
-
|
|
158
|
+
if (hasJson) {
|
|
159
|
+
deserializeJsonFields(record, jsonColumns);
|
|
160
|
+
}
|
|
147
161
|
|
|
148
162
|
if (withs.length > 0) {
|
|
149
163
|
await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
|
|
150
164
|
}
|
|
151
165
|
|
|
152
|
-
|
|
166
|
+
if (shouldEmit) {
|
|
167
|
+
ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
|
|
168
|
+
}
|
|
153
169
|
|
|
154
170
|
return record;
|
|
155
171
|
}
|
|
@@ -183,12 +199,18 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
183
199
|
*/
|
|
184
200
|
async function findAll(options = {}) {
|
|
185
201
|
const { with: withs = [], select } = options;
|
|
186
|
-
const
|
|
202
|
+
const shouldEmit = (
|
|
203
|
+
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
204
|
+
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
205
|
+
);
|
|
206
|
+
const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
|
|
187
207
|
|
|
188
208
|
async function loadFromDb() {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
209
|
+
if (shouldEmit) {
|
|
210
|
+
await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
211
|
+
if (ctx.isCancelled) {
|
|
212
|
+
throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
|
|
213
|
+
}
|
|
192
214
|
}
|
|
193
215
|
|
|
194
216
|
let qb = baseQuery();
|
|
@@ -199,16 +221,22 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
199
221
|
|
|
200
222
|
const records = await qb;
|
|
201
223
|
|
|
202
|
-
|
|
203
|
-
|
|
224
|
+
if (hasJson) {
|
|
225
|
+
const len = records.length;
|
|
226
|
+
for (let i = 0; i < len; i++) {
|
|
227
|
+
deserializeJsonFields(records[i], jsonColumns);
|
|
228
|
+
}
|
|
204
229
|
}
|
|
205
230
|
|
|
206
231
|
if (withs.length > 0 && records.length > 0) {
|
|
207
232
|
await loadRelations(records, ensureArray(withs), model, knex, scopeContext);
|
|
208
233
|
}
|
|
209
234
|
|
|
210
|
-
|
|
211
|
-
|
|
235
|
+
if (shouldEmit) {
|
|
236
|
+
const len = records.length;
|
|
237
|
+
for (let i = 0; i < len; i++) {
|
|
238
|
+
ModelEvents.emit(model.name, Hooks.AFTER_FIND, records[i], ctx);
|
|
239
|
+
}
|
|
212
240
|
}
|
|
213
241
|
|
|
214
242
|
return records;
|
|
@@ -261,7 +289,9 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
261
289
|
let insertData = applyInsertModifiers(validated, scopeContext, model);
|
|
262
290
|
|
|
263
291
|
// Serialize JSON fields
|
|
264
|
-
|
|
292
|
+
if (hasJson) {
|
|
293
|
+
insertData = serializeJsonFields(insertData, jsonColumns);
|
|
294
|
+
}
|
|
265
295
|
|
|
266
296
|
// Insert and return the record
|
|
267
297
|
const [id] = await knex(model.table).insert(insertData).returning(model.primaryKey);
|
|
@@ -341,7 +371,9 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
341
371
|
delete updateData[model.primaryKey];
|
|
342
372
|
|
|
343
373
|
// Serialize JSON fields
|
|
344
|
-
|
|
374
|
+
if (hasJson) {
|
|
375
|
+
updateData = serializeJsonFields(updateData, jsonColumns);
|
|
376
|
+
}
|
|
345
377
|
|
|
346
378
|
// Update the record
|
|
347
379
|
const updated = await baseQuery()
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const METADATA_MARKER = '__wdb__';
|
|
8
|
+
const metaWeakMap = new WeakMap();
|
|
9
|
+
const metaStringCache = new Map();
|
|
10
|
+
const schemaColumnsWeakMap = new WeakMap();
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Encode column metadata into a JSON string for Zod .describe()
|
|
@@ -22,14 +25,25 @@ function encodeColumnMeta(meta) {
|
|
|
22
25
|
*/
|
|
23
26
|
function decodeColumnMeta(description) {
|
|
24
27
|
if (!description) return null;
|
|
28
|
+
const cached = metaStringCache.get(description);
|
|
29
|
+
if (cached !== undefined) return cached;
|
|
30
|
+
|
|
25
31
|
try {
|
|
26
32
|
const parsed = JSON.parse(description);
|
|
27
33
|
if (parsed && parsed[METADATA_MARKER]) {
|
|
28
|
-
|
|
34
|
+
const meta = parsed.meta;
|
|
35
|
+
if (metaStringCache.size < 500) {
|
|
36
|
+
metaStringCache.set(description, meta);
|
|
37
|
+
}
|
|
38
|
+
return meta;
|
|
29
39
|
}
|
|
30
40
|
} catch {
|
|
31
41
|
// Not our metadata, ignore
|
|
32
42
|
}
|
|
43
|
+
|
|
44
|
+
if (metaStringCache.size < 500) {
|
|
45
|
+
metaStringCache.set(description, null);
|
|
46
|
+
}
|
|
33
47
|
return null;
|
|
34
48
|
}
|
|
35
49
|
|
|
@@ -39,7 +53,7 @@ function decodeColumnMeta(description) {
|
|
|
39
53
|
* @returns {boolean}
|
|
40
54
|
*/
|
|
41
55
|
function hasColumnMeta(schema) {
|
|
42
|
-
return
|
|
56
|
+
return getColumnMeta(schema) !== null;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
/**
|
|
@@ -48,6 +62,10 @@ function hasColumnMeta(schema) {
|
|
|
48
62
|
* @returns {import('./types').ColumnMeta|null}
|
|
49
63
|
*/
|
|
50
64
|
function getColumnMeta(schema) {
|
|
65
|
+
if (!schema) return null;
|
|
66
|
+
const direct = metaWeakMap.get(schema);
|
|
67
|
+
if (direct !== undefined) return direct;
|
|
68
|
+
if (schema._wdbMeta) return schema._wdbMeta;
|
|
51
69
|
return decodeColumnMeta(schema.description);
|
|
52
70
|
}
|
|
53
71
|
|
|
@@ -253,6 +271,7 @@ class SchemaBuilder {
|
|
|
253
271
|
|
|
254
272
|
// Apply metadata to schema
|
|
255
273
|
this._schema = this._schema.describe(encodeColumnMeta(finalMeta));
|
|
274
|
+
metaWeakMap.set(this._schema, finalMeta);
|
|
256
275
|
this._finalized = true;
|
|
257
276
|
return this._schema;
|
|
258
277
|
}
|
|
@@ -770,6 +789,15 @@ function createSchemaHelpers(z) {
|
|
|
770
789
|
* @returns {Map<string, import('./types').ColumnMeta>}
|
|
771
790
|
*/
|
|
772
791
|
function extractColumnsFromSchema(schema) {
|
|
792
|
+
if (!schema || typeof schema !== 'object' || !schema.shape) {
|
|
793
|
+
return new Map();
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const cached = schemaColumnsWeakMap.get(schema);
|
|
797
|
+
if (cached !== undefined) {
|
|
798
|
+
return cached;
|
|
799
|
+
}
|
|
800
|
+
|
|
773
801
|
const columns = new Map();
|
|
774
802
|
const shape = schema.shape;
|
|
775
803
|
|
|
@@ -806,6 +834,7 @@ function extractColumnsFromSchema(schema) {
|
|
|
806
834
|
}
|
|
807
835
|
}
|
|
808
836
|
|
|
837
|
+
schemaColumnsWeakMap.set(schema, columns);
|
|
809
838
|
return columns;
|
|
810
839
|
}
|
|
811
840
|
|
package/core/orm/scopes.js
CHANGED
|
@@ -185,11 +185,12 @@ function applyUpdateModifiers(data, model) {
|
|
|
185
185
|
* Create default scope context
|
|
186
186
|
* @returns {import('./types').ScopeContext}
|
|
187
187
|
*/
|
|
188
|
-
function createScopeContext() {
|
|
188
|
+
function createScopeContext(overrides = {}) {
|
|
189
189
|
return {
|
|
190
190
|
tenantId: undefined,
|
|
191
191
|
withTrashed: false,
|
|
192
192
|
onlyTrashed: false,
|
|
193
|
+
...overrides,
|
|
193
194
|
};
|
|
194
195
|
}
|
|
195
196
|
|
package/package.json
CHANGED
|
@@ -171,6 +171,16 @@ var routes = {
|
|
|
171
171
|
return ProfilePage;
|
|
172
172
|
}
|
|
173
173
|
},
|
|
174
|
+
'/admins': {
|
|
175
|
+
onmatch: async () => {
|
|
176
|
+
const isAuth = await checkAuth();
|
|
177
|
+
if (!isAuth) {
|
|
178
|
+
redirectToLogin();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
return AdminUsersPage;
|
|
182
|
+
}
|
|
183
|
+
},
|
|
174
184
|
'/users/sessions': {
|
|
175
185
|
onmatch: async () => {
|
|
176
186
|
if (!(await guardUserManagementRoutes())) return;
|
|
@@ -16,52 +16,59 @@ const Pagination = {
|
|
|
16
16
|
pages.push(i);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
return m('.flex.items-center.justify-between.px-4.py-3.bg-white
|
|
20
|
-
m('.text-sm.text-gray-
|
|
19
|
+
return m('.flex.flex-col.sm:flex-row.items-center.justify-between.gap-3.px-4.py-3.bg-white.dark:bg-slate-800.border-t.border-gray-200.dark:border-slate-700', [
|
|
20
|
+
m('.text-xs.sm:text-sm.text-gray-600.dark:text-slate-400.text-center.sm:text-left', [
|
|
21
21
|
'Showing ',
|
|
22
|
-
m('span.font-
|
|
22
|
+
m('span.font-semibold.text-gray-900.dark:text-slate-200', ((page - 1) * perPage) + 1),
|
|
23
23
|
' to ',
|
|
24
|
-
m('span.font-
|
|
24
|
+
m('span.font-semibold.text-gray-900.dark:text-slate-200', Math.min(page * perPage, total)),
|
|
25
25
|
' of ',
|
|
26
|
-
m('span.font-
|
|
26
|
+
m('span.font-semibold.text-gray-900.dark:text-slate-200', total),
|
|
27
27
|
' results',
|
|
28
28
|
]),
|
|
29
|
-
m('nav.flex.items-center.
|
|
29
|
+
m('nav.flex.items-center.gap-1', [
|
|
30
30
|
// Previous button
|
|
31
|
-
m('button.px-3.py-1.rounded.border.text-sm', {
|
|
31
|
+
m('button.px-3.py-1.5.rounded-lg.border.border-gray-200.dark:border-slate-600.text-xs.sm:text-sm.font-medium.transition-colors', {
|
|
32
32
|
disabled: page <= 1,
|
|
33
|
-
class: page <= 1
|
|
33
|
+
class: page <= 1
|
|
34
|
+
? 'text-gray-300 dark:text-slate-600 cursor-not-allowed bg-gray-50 dark:bg-slate-900/50'
|
|
35
|
+
: 'text-gray-700 dark:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 bg-white dark:bg-slate-800',
|
|
34
36
|
onclick: () => page > 1 && onPageChange(page - 1),
|
|
35
37
|
}, '← Prev'),
|
|
38
|
+
|
|
39
|
+
// Mobile page indicator
|
|
40
|
+
m('span.sm:hidden.px-2.text-xs.font-medium.text-gray-600.dark:text-slate-400', `${page} / ${totalPages}`),
|
|
36
41
|
|
|
37
|
-
// Page numbers
|
|
42
|
+
// Page numbers (desktop only)
|
|
38
43
|
start > 1 ? [
|
|
39
|
-
m('button.px-3.py-1.rounded.text-sm.text-gray-700
|
|
44
|
+
m('button.hidden.sm:inline-flex.px-3.py-1.5.rounded-lg.text-xs.sm:text-sm.font-medium.text-gray-700.dark:text-slate-300.hover:bg-gray-100.dark:hover:bg-slate-700.transition-colors', {
|
|
40
45
|
onclick: () => onPageChange(1),
|
|
41
46
|
}, '1'),
|
|
42
|
-
start > 2 ? m('span.px-
|
|
47
|
+
start > 2 ? m('span.hidden.sm:inline-flex.px-1.text-gray-400.dark:text-slate-500', '...') : null,
|
|
43
48
|
] : null,
|
|
44
49
|
|
|
45
50
|
...pages.map(p =>
|
|
46
|
-
m('button.px-3.py-1.rounded.text-sm', {
|
|
51
|
+
m('button.hidden.sm:inline-flex.px-3.py-1.5.rounded-lg.text-xs.sm:text-sm.font-medium.transition-colors', {
|
|
47
52
|
class: p === page
|
|
48
|
-
? 'bg-blue-600 text-white'
|
|
49
|
-
: 'text-gray-700
|
|
53
|
+
? 'bg-blue-600 dark:bg-blue-500 text-white shadow-sm'
|
|
54
|
+
: 'text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700',
|
|
50
55
|
onclick: () => onPageChange(p),
|
|
51
56
|
}, p)
|
|
52
57
|
),
|
|
53
58
|
|
|
54
59
|
end < totalPages ? [
|
|
55
|
-
end < totalPages - 1 ? m('span.px-
|
|
56
|
-
m('button.px-3.py-1.rounded.text-sm.text-gray-700
|
|
60
|
+
end < totalPages - 1 ? m('span.hidden.sm:inline-flex.px-1.text-gray-400.dark:text-slate-500', '...') : null,
|
|
61
|
+
m('button.hidden.sm:inline-flex.px-3.py-1.5.rounded-lg.text-xs.sm:text-sm.font-medium.text-gray-700.dark:text-slate-300.hover:bg-gray-100.dark:hover:bg-slate-700.transition-colors', {
|
|
57
62
|
onclick: () => onPageChange(totalPages),
|
|
58
63
|
}, totalPages),
|
|
59
64
|
] : null,
|
|
60
65
|
|
|
61
66
|
// Next button
|
|
62
|
-
m('button.px-3.py-1.rounded.border.text-sm', {
|
|
67
|
+
m('button.px-3.py-1.5.rounded-lg.border.border-gray-200.dark:border-slate-600.text-xs.sm:text-sm.font-medium.transition-colors', {
|
|
63
68
|
disabled: page >= totalPages,
|
|
64
|
-
class: page >= totalPages
|
|
69
|
+
class: page >= totalPages
|
|
70
|
+
? 'text-gray-300 dark:text-slate-600 cursor-not-allowed bg-gray-50 dark:bg-slate-900/50'
|
|
71
|
+
: 'text-gray-700 dark:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 bg-white dark:bg-slate-800',
|
|
65
72
|
onclick: () => page < totalPages && onPageChange(page + 1),
|
|
66
73
|
}, 'Next →'),
|
|
67
74
|
]),
|
|
@@ -61,7 +61,7 @@ const RecordList = {
|
|
|
61
61
|
|
|
62
62
|
return m(Layout, { breadcrumbs }, [
|
|
63
63
|
// Header
|
|
64
|
-
m('.flex.items-center.justify-between.mb-4', [
|
|
64
|
+
m('.flex.flex-col.sm:flex-row.sm:items-center.sm:justify-between.gap-3.mb-4', [
|
|
65
65
|
m('.flex.items-center.gap-3', [
|
|
66
66
|
m('h2.text-2xl.font-bold', modelMeta?.label || modelName),
|
|
67
67
|
m(RefreshIconButton, {
|
|
@@ -76,16 +76,16 @@ const RecordList = {
|
|
|
76
76
|
});
|
|
77
77
|
},
|
|
78
78
|
}),
|
|
79
|
-
modelMeta?.softDelete ? m('.flex.rounded-lg.border.border-gray-200
|
|
79
|
+
modelMeta?.softDelete ? m('.flex.rounded-lg.border.border-gray-200.dark:border-slate-600.p-0.5', [
|
|
80
80
|
m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
|
|
81
|
-
class: !state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900
|
|
81
|
+
class: !state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900.dark:hover:text-slate-100',
|
|
82
82
|
onclick: () => {
|
|
83
83
|
state.trashedView = false;
|
|
84
84
|
loadRecords(modelName, 1);
|
|
85
85
|
},
|
|
86
86
|
}, 'Active'),
|
|
87
87
|
m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
|
|
88
|
-
class: state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900
|
|
88
|
+
class: state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900.dark:hover:text-slate-100',
|
|
89
89
|
onclick: () => {
|
|
90
90
|
state.trashedView = true;
|
|
91
91
|
loadRecords(modelName, 1);
|
|
@@ -232,29 +232,29 @@ const RecordList = {
|
|
|
232
232
|
])
|
|
233
233
|
: m('.bg-white dark:bg-slate-800.rounded-lg.shadow-sm.border.border-gray-200 dark:border-slate-600.overflow-hidden', [
|
|
234
234
|
// Bulk Actions Toolbar (shown when items selected)
|
|
235
|
-
(state.selectedRecords && state.selectedRecords.size > 0) || state.selectAllMode ? m('.bg-indigo-50.border-b.border-indigo-100.px-4.py-3.flex.items-center.justify-between', [
|
|
236
|
-
m('.flex.items-center.gap-
|
|
237
|
-
m('span.text-sm.text-indigo-700.font-medium',
|
|
235
|
+
(state.selectedRecords && state.selectedRecords.size > 0) || state.selectAllMode ? m('.bg-indigo-50.dark:bg-indigo-950/40.border-b.border-indigo-100.dark:border-indigo-900/50.px-4.py-3.flex.flex-col.sm:flex-row.sm:items-center.sm:justify-between.gap-3', [
|
|
236
|
+
m('.flex.flex-wrap.items-center.gap-2', [
|
|
237
|
+
m('span.text-sm.text-indigo-700.dark:text-indigo-300.font-medium',
|
|
238
238
|
state.selectAllMode
|
|
239
239
|
? 'All ' + state.pagination.total + ' records selected'
|
|
240
240
|
: state.selectedRecords.size + ' record' + (state.selectedRecords.size > 1 ? 's' : '') + ' selected'
|
|
241
241
|
),
|
|
242
242
|
// Show "Select all X records" option when current page is fully selected
|
|
243
|
-
!state.selectAllMode && state.selectedRecords.size === state.records.length && state.pagination.total > state.records.length && m('button.text-sm.text-indigo-600.hover:text-indigo-800.underline.font-medium', {
|
|
243
|
+
!state.selectAllMode && state.selectedRecords.size === state.records.length && state.pagination.total > state.records.length && m('button.text-sm.text-indigo-600.dark:text-indigo-400.hover:text-indigo-800.underline.font-medium', {
|
|
244
244
|
onclick: () => {
|
|
245
245
|
state.selectAllMode = true;
|
|
246
246
|
m.redraw();
|
|
247
247
|
},
|
|
248
248
|
}, 'Select all ' + state.pagination.total + ' records'),
|
|
249
249
|
// Show "Select only this page" when in selectAllMode
|
|
250
|
-
state.selectAllMode && m('button.text-sm.text-indigo-600.hover:text-indigo-800.underline', {
|
|
250
|
+
state.selectAllMode && m('button.text-sm.text-indigo-600.dark:text-indigo-400.hover:text-indigo-800.underline', {
|
|
251
251
|
onclick: () => {
|
|
252
252
|
state.selectAllMode = false;
|
|
253
253
|
m.redraw();
|
|
254
254
|
},
|
|
255
255
|
}, 'Select only this page (' + state.selectedRecords.size + ')'),
|
|
256
256
|
]),
|
|
257
|
-
m('.flex.items-center.gap-
|
|
257
|
+
m('.flex.flex-wrap.items-center.gap-1.5', [
|
|
258
258
|
state.trashedView && modelMeta?.softDelete
|
|
259
259
|
? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-green-600.bg-white dark:bg-slate-800.border.border-green-200.rounded.hover:bg-green-50.transition-colors', {
|
|
260
260
|
disabled: state.bulkActionInProgress,
|
|
@@ -16,6 +16,7 @@ const { registerDefaultBulkActions, generateBulkActionsComponent } = require('./
|
|
|
16
16
|
const { registerDefaultPages, createCustomPageApiHandlers, generateCustomPageComponent } = require('./modules/custom-pages');
|
|
17
17
|
const { registerModelMenuItems, registerSystemMenuItems, generateMenuComponent } = require('./modules/menu');
|
|
18
18
|
const { generateProfileComponent } = require('./modules/profile');
|
|
19
|
+
const { registerAdminUsersManagement, createAdminUsersApiHandlers, generateAdminUsersComponent } = require('./modules/admin-users');
|
|
19
20
|
const { registerModule, registerPageDir } = require('./core/admin-module');
|
|
20
21
|
|
|
21
22
|
|
|
@@ -44,11 +45,14 @@ function adminPanelPlugin(options = {}) {
|
|
|
44
45
|
db,
|
|
45
46
|
auth,
|
|
46
47
|
userManagement: userMgmtConfig,
|
|
48
|
+
adminUsers: adminUsersOption = true,
|
|
47
49
|
configure,
|
|
48
50
|
uploadUrl: uploadUrlOption,
|
|
49
51
|
richTextSanitize = true,
|
|
50
52
|
} = options;
|
|
51
53
|
|
|
54
|
+
const adminUsersConfig = typeof adminUsersOption === 'object' ? adminUsersOption : { enabled: adminUsersOption !== false };
|
|
55
|
+
|
|
52
56
|
// Validate required options
|
|
53
57
|
if (!db) {
|
|
54
58
|
throw new Error('Admin panel plugin requires a database instance. Pass `db` in options.');
|
|
@@ -152,13 +156,20 @@ function adminPanelPlugin(options = {}) {
|
|
|
152
156
|
// Session must be registered before file routes so SSR pages can read adminUser.
|
|
153
157
|
if (enabled && ctx.app && !ctx.app._webspressoSessionInitialized) {
|
|
154
158
|
const secret = sessionSecret || process.env.SESSION_SECRET || 'webspresso-admin-secret-change-in-production';
|
|
159
|
+
const secureMode =
|
|
160
|
+
process.env.NODE_ENV === 'production' ||
|
|
161
|
+
process.env.COOKIE_SECURE === 'true' ||
|
|
162
|
+
/^https:/i.test(String(process.env.BASE_URL || '').trim())
|
|
163
|
+
? true
|
|
164
|
+
: 'auto';
|
|
155
165
|
ctx.app.use(session({
|
|
156
166
|
secret,
|
|
157
167
|
resave: false,
|
|
158
168
|
saveUninitialized: false,
|
|
159
169
|
cookie: {
|
|
160
|
-
secure:
|
|
170
|
+
secure: secureMode,
|
|
161
171
|
httpOnly: true,
|
|
172
|
+
sameSite: 'lax',
|
|
162
173
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
163
174
|
},
|
|
164
175
|
}));
|
|
@@ -227,6 +238,14 @@ function adminPanelPlugin(options = {}) {
|
|
|
227
238
|
});
|
|
228
239
|
}
|
|
229
240
|
|
|
241
|
+
// Register admin user management if enabled
|
|
242
|
+
if (adminUsersConfig.enabled !== false) {
|
|
243
|
+
registerAdminUsersManagement({
|
|
244
|
+
registry,
|
|
245
|
+
config: adminUsersConfig,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
230
249
|
// Create API handlers
|
|
231
250
|
const apiHandlers = createApiHandlers({
|
|
232
251
|
path: adminPath,
|
|
@@ -245,6 +264,16 @@ function adminPanelPlugin(options = {}) {
|
|
|
245
264
|
|
|
246
265
|
const pageHandlers = createCustomPageApiHandlers({ registry, db });
|
|
247
266
|
|
|
267
|
+
// Admin users management API handlers
|
|
268
|
+
let adminUsersHandlers = null;
|
|
269
|
+
if (adminUsersConfig.enabled !== false) {
|
|
270
|
+
adminUsersHandlers = createAdminUsersApiHandlers({
|
|
271
|
+
db,
|
|
272
|
+
AdminUser,
|
|
273
|
+
hashPassword: (password, rounds) => bcrypt.hash(password, rounds),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
248
277
|
// User management API handlers
|
|
249
278
|
let userHandlers = null;
|
|
250
279
|
if (userMgmtConfig?.enabled) {
|
|
@@ -289,6 +318,15 @@ function adminPanelPlugin(options = {}) {
|
|
|
289
318
|
ctx.addRoute('get', `${adminPath}/api/extensions/pages/:pageId/data`, requireAuth, pageHandlers.getPageData);
|
|
290
319
|
ctx.addRoute('all', `${adminPath}/api/extensions/pages/:pageId/actions/:actionId`, requireAuth, pageHandlers.executePageAction);
|
|
291
320
|
|
|
321
|
+
// Admin users management API routes
|
|
322
|
+
if (adminUsersHandlers) {
|
|
323
|
+
ctx.addRoute('get', `${adminPath}/api/admins`, requireAuth, adminUsersHandlers.listAdmins);
|
|
324
|
+
ctx.addRoute('get', `${adminPath}/api/admins/:id`, requireAuth, adminUsersHandlers.getAdmin);
|
|
325
|
+
ctx.addRoute('post', `${adminPath}/api/admins`, requireAuth, adminUsersHandlers.createAdmin);
|
|
326
|
+
ctx.addRoute('put', `${adminPath}/api/admins/:id`, requireAuth, adminUsersHandlers.updateAdmin);
|
|
327
|
+
ctx.addRoute('delete', `${adminPath}/api/admins/:id`, requireAuth, adminUsersHandlers.deleteAdmin);
|
|
328
|
+
}
|
|
329
|
+
|
|
292
330
|
// User management API routes
|
|
293
331
|
if (userHandlers) {
|
|
294
332
|
ctx.addRoute('get', `${adminPath}/api/users`, requireAuth, userHandlers.listUsers);
|
|
@@ -391,6 +429,7 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
391
429
|
const bulkActionsComponent = generateBulkActionsComponent();
|
|
392
430
|
const customPageComponent = generateCustomPageComponent();
|
|
393
431
|
const profileComponent = generateProfileComponent();
|
|
432
|
+
const adminUsersComponent = generateAdminUsersComponent();
|
|
394
433
|
|
|
395
434
|
const settings = registry.settings;
|
|
396
435
|
const fieldRenderersCode = Array.from(registry.fieldRenderers.entries()).map(([type, r]) => {
|
|
@@ -473,6 +512,7 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
473
512
|
${bulkActionsComponent}
|
|
474
513
|
${customPageComponent}
|
|
475
514
|
${profileComponent}
|
|
515
|
+
${adminUsersComponent}
|
|
476
516
|
|
|
477
517
|
// Custom page components container
|
|
478
518
|
window.__customPages = window.__customPages || {};
|