webspresso 0.0.73 → 0.0.75
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 +44 -4
- package/bin/commands/orm-map.js +139 -0
- package/bin/commands/skill.js +22 -8
- package/bin/commands/upgrade.js +146 -0
- package/bin/utils/orm-map-html.js +689 -0
- package/bin/utils/orm-map-load.js +85 -0
- package/bin/utils/orm-map-snapshot.js +179 -0
- package/bin/utils/resolve-webspresso-orm.js +23 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/manager.js +14 -1
- package/core/kernel/app.js +96 -0
- package/core/kernel/base-repository.js +143 -0
- package/core/kernel/events.js +101 -0
- package/core/kernel/flow.js +22 -0
- package/core/kernel/index.js +17 -0
- package/core/kernel/plugin.js +23 -0
- package/core/kernel/plugins/sample-seo.js +26 -0
- package/core/kernel/run-demo.js +58 -0
- package/core/kernel/view.js +167 -0
- package/core/openapi/build-from-api-routes.js +8 -2
- package/core/orm/model.js +3 -1
- package/core/url-path-normalize.js +30 -0
- package/index.d.ts +168 -1
- package/index.js +20 -2
- package/package.json +11 -1
- package/plugins/admin-panel/api.js +43 -15
- package/plugins/admin-panel/app.js +109 -0
- package/plugins/admin-panel/client/README.md +39 -0
- package/plugins/admin-panel/client/load-parts.js +74 -0
- package/plugins/admin-panel/client/manifest.parts.json +12 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
- package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
- package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
- package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
- package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
- package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
- package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
- package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
- package/plugins/admin-panel/components.js +4 -2640
- package/plugins/admin-panel/core/api-extensions.js +100 -10
- package/plugins/admin-panel/index.js +3 -0
- package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
- package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
- package/plugins/admin-panel/modules/dashboard.js +17 -13
- package/plugins/admin-panel/modules/user-management.js +118 -27
- package/plugins/data-exchange/export-xlsx.js +3 -0
- package/plugins/data-exchange/record-selection.js +21 -5
- package/plugins/index.js +4 -0
- package/plugins/rate-limit/index.js +178 -0
- package/plugins/redirect/index.js +204 -0
- package/plugins/rest-resources/index.js +2 -1
- package/plugins/site-analytics/admin-component.js +88 -78
- package/plugins/swagger.js +2 -1
- package/plugins/upload/local-file-provider.js +6 -2
- package/src/file-router.js +270 -53
- package/src/njk-frontmatter.js +156 -0
- package/src/plugin-manager.js +4 -2
- package/src/server.js +28 -9
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
- package/templates/skills/webspresso-usage/SKILL.md +29 -275
|
@@ -25,10 +25,21 @@ function buildFilteredQuery(repo, filters, options = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
for (const [column, filter] of Object.entries(filters)) {
|
|
28
|
-
if (!filter
|
|
29
|
-
|
|
28
|
+
if (!filter) continue;
|
|
29
|
+
|
|
30
30
|
const op = filter.op || filter.operator || 'contains';
|
|
31
|
-
|
|
31
|
+
|
|
32
|
+
if (op === 'is_null') {
|
|
33
|
+
query = query.whereNull(column);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (op === 'is_not_null') {
|
|
37
|
+
query = query.whereNotNull(column);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (filter.value === '' && !filter.from && !filter.to) continue;
|
|
42
|
+
|
|
32
43
|
switch (op) {
|
|
33
44
|
case 'contains':
|
|
34
45
|
query = query.where(column, 'like', `%${filter.value}%`);
|
|
@@ -86,6 +97,60 @@ async function getAllMatchingIds(repo, filters, primaryKey = 'id', options = {})
|
|
|
86
97
|
return records.map(r => r[primaryKey]);
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
const BULK_TEMPORAL_TYPES = new Set(['date', 'datetime', 'timestamp']);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Bulk field update: coerce date / datetime / timestamp body values to Date (or null).
|
|
104
|
+
* @param {object} columnMeta - ORM column metadata
|
|
105
|
+
* @param {unknown} raw
|
|
106
|
+
* @param {string} fieldName
|
|
107
|
+
* @returns {{ value: Date|null }|{ error: string }}
|
|
108
|
+
*/
|
|
109
|
+
function coerceBulkTemporalValue(columnMeta, raw, fieldName) {
|
|
110
|
+
const nullable = !!columnMeta.nullable;
|
|
111
|
+
const empty =
|
|
112
|
+
raw === null ||
|
|
113
|
+
raw === undefined ||
|
|
114
|
+
(typeof raw === 'string' && raw.trim() === '');
|
|
115
|
+
|
|
116
|
+
if (empty) {
|
|
117
|
+
if (nullable) return { value: null };
|
|
118
|
+
return { error: `Field "${fieldName}" requires a value` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (columnMeta.type === 'date') {
|
|
122
|
+
if (typeof raw !== 'string') return { error: `Invalid date value for "${fieldName}"` };
|
|
123
|
+
const s = raw.trim();
|
|
124
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
|
125
|
+
return { error: `Date must be YYYY-MM-DD for "${fieldName}"` };
|
|
126
|
+
}
|
|
127
|
+
const d = new Date(`${s}T12:00:00.000Z`);
|
|
128
|
+
if (Number.isNaN(d.getTime())) return { error: `Invalid date for "${fieldName}"` };
|
|
129
|
+
return { value: d };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (columnMeta.type === 'datetime' || columnMeta.type === 'timestamp') {
|
|
133
|
+
if (typeof raw !== 'string') return { error: `Invalid datetime value for "${fieldName}"` };
|
|
134
|
+
const s = raw.trim();
|
|
135
|
+
let d;
|
|
136
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(s)) {
|
|
137
|
+
d = new Date(s);
|
|
138
|
+
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(s)) {
|
|
139
|
+
d = new Date(s);
|
|
140
|
+
} else if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
|
141
|
+
d = new Date(`${s}T00:00:00`);
|
|
142
|
+
} else {
|
|
143
|
+
d = new Date(s);
|
|
144
|
+
}
|
|
145
|
+
if (Number.isNaN(d.getTime())) {
|
|
146
|
+
return { error: `Invalid datetime for "${fieldName}"` };
|
|
147
|
+
}
|
|
148
|
+
return { value: d };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { error: `Field "${fieldName}" is not a temporal type` };
|
|
152
|
+
}
|
|
153
|
+
|
|
89
154
|
/**
|
|
90
155
|
* Create extension API handlers
|
|
91
156
|
* @param {Object} options - Options
|
|
@@ -258,7 +323,7 @@ function createExtensionApiHandlers(options) {
|
|
|
258
323
|
}
|
|
259
324
|
|
|
260
325
|
/**
|
|
261
|
-
* Bulk update field values (
|
|
326
|
+
* Bulk update field values (enum, boolean, date, datetime, timestamp)
|
|
262
327
|
* Supports both specific IDs and selectAll mode with filters
|
|
263
328
|
*/
|
|
264
329
|
async function bulkUpdateFieldHandler(req, res) {
|
|
@@ -283,13 +348,18 @@ function createExtensionApiHandlers(options) {
|
|
|
283
348
|
return res.status(400).json({ error: `Field "${field}" not found in model` });
|
|
284
349
|
}
|
|
285
350
|
|
|
286
|
-
// Validate field type - only allow enum and boolean
|
|
287
351
|
const enumValues = columnMeta.enumValues || columnMeta.enum;
|
|
288
352
|
const isEnum = enumValues && Array.isArray(enumValues);
|
|
289
353
|
const isBoolean = columnMeta.type === 'boolean';
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
354
|
+
const isTemporal =
|
|
355
|
+
BULK_TEMPORAL_TYPES.has(columnMeta.type) &&
|
|
356
|
+
columnMeta.auto !== 'create' &&
|
|
357
|
+
columnMeta.auto !== 'update';
|
|
358
|
+
|
|
359
|
+
if (!isEnum && !isBoolean && !isTemporal) {
|
|
360
|
+
return res.status(400).json({
|
|
361
|
+
error: `Field "${field}" is not bulk-updatable (allowed: enum, boolean, date, datetime, timestamp)`,
|
|
362
|
+
});
|
|
293
363
|
}
|
|
294
364
|
|
|
295
365
|
// Validate value for enum fields
|
|
@@ -297,10 +367,15 @@ function createExtensionApiHandlers(options) {
|
|
|
297
367
|
return res.status(400).json({ error: `Invalid value "${value}" for enum field "${field}"` });
|
|
298
368
|
}
|
|
299
369
|
|
|
300
|
-
// Coerce boolean value
|
|
301
370
|
let updateValue = value;
|
|
302
371
|
if (isBoolean) {
|
|
303
372
|
updateValue = value === true || value === 'true' || value === 1 || value === '1';
|
|
373
|
+
} else if (isTemporal) {
|
|
374
|
+
const coerced = coerceBulkTemporalValue(columnMeta, value, field);
|
|
375
|
+
if (coerced.error) {
|
|
376
|
+
return res.status(400).json({ error: coerced.error });
|
|
377
|
+
}
|
|
378
|
+
updateValue = coerced.value;
|
|
304
379
|
}
|
|
305
380
|
|
|
306
381
|
// Get repository and determine IDs
|
|
@@ -351,7 +426,7 @@ function createExtensionApiHandlers(options) {
|
|
|
351
426
|
}
|
|
352
427
|
|
|
353
428
|
/**
|
|
354
|
-
* Get bulk-updatable fields for a model (enum
|
|
429
|
+
* Get bulk-updatable fields for a model (enum, boolean, date, datetime, timestamp)
|
|
355
430
|
*/
|
|
356
431
|
function bulkFieldsHandler(req, res) {
|
|
357
432
|
try {
|
|
@@ -364,14 +439,21 @@ function createExtensionApiHandlers(options) {
|
|
|
364
439
|
return res.status(404).json({ error: 'Model not found or not enabled' });
|
|
365
440
|
}
|
|
366
441
|
|
|
442
|
+
const hiddenSet = new Set(model.hidden || []);
|
|
367
443
|
const bulkFields = [];
|
|
368
444
|
|
|
369
445
|
// model.columns is a Map<string, ColumnMeta> - values are already metadata objects
|
|
370
446
|
for (const [fieldName, columnMeta] of model.columns.entries()) {
|
|
447
|
+
if (hiddenSet.has(fieldName)) continue;
|
|
448
|
+
|
|
371
449
|
// Check for enum - schema uses 'enumValues' property
|
|
372
450
|
const enumValues = columnMeta.enumValues || columnMeta.enum;
|
|
373
451
|
const isEnum = enumValues && Array.isArray(enumValues);
|
|
374
452
|
const isBoolean = columnMeta.type === 'boolean';
|
|
453
|
+
const isTemporal =
|
|
454
|
+
BULK_TEMPORAL_TYPES.has(columnMeta.type) &&
|
|
455
|
+
columnMeta.auto !== 'create' &&
|
|
456
|
+
columnMeta.auto !== 'update';
|
|
375
457
|
|
|
376
458
|
if (isEnum) {
|
|
377
459
|
bulkFields.push({
|
|
@@ -390,6 +472,13 @@ function createExtensionApiHandlers(options) {
|
|
|
390
472
|
{ value: false, label: 'False' },
|
|
391
473
|
],
|
|
392
474
|
});
|
|
475
|
+
} else if (isTemporal) {
|
|
476
|
+
bulkFields.push({
|
|
477
|
+
name: fieldName,
|
|
478
|
+
type: columnMeta.type,
|
|
479
|
+
label: columnMeta.label || fieldName,
|
|
480
|
+
nullable: !!columnMeta.nullable,
|
|
481
|
+
});
|
|
393
482
|
}
|
|
394
483
|
}
|
|
395
484
|
|
|
@@ -660,4 +749,5 @@ module.exports = {
|
|
|
660
749
|
createExtensionApiHandlers,
|
|
661
750
|
buildFilteredQuery,
|
|
662
751
|
getAllMatchingIds,
|
|
752
|
+
coerceBulkTemporalValue,
|
|
663
753
|
};
|
|
@@ -31,6 +31,7 @@ const { registerModule } = require('./core/admin-module');
|
|
|
31
31
|
* @param {Object} [options.userManagement.fields] - Field mappings
|
|
32
32
|
* @param {Function} [options.configure] - Configuration callback (registry) => void
|
|
33
33
|
* @param {string} [options.uploadUrl] - POST URL for file uploads (overrides app.get('webspresso.uploadPath') from uploadPlugin)
|
|
34
|
+
* @param {boolean} [options.richTextSanitize=true] - Sanitize HTML for admin rich-text fields on create/update (set false only if you accept XSS risk)
|
|
34
35
|
* @returns {Object} Plugin definition
|
|
35
36
|
*/
|
|
36
37
|
function adminPanelPlugin(options = {}) {
|
|
@@ -43,6 +44,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
43
44
|
userManagement: userMgmtConfig,
|
|
44
45
|
configure,
|
|
45
46
|
uploadUrl: uploadUrlOption,
|
|
47
|
+
richTextSanitize = true,
|
|
46
48
|
} = options;
|
|
47
49
|
|
|
48
50
|
// Validate required options
|
|
@@ -204,6 +206,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
204
206
|
AdminUser,
|
|
205
207
|
hashPassword: (password, rounds) => bcrypt.hash(password, rounds),
|
|
206
208
|
comparePassword: (password, hash) => bcrypt.compare(password, hash),
|
|
209
|
+
richTextSanitize,
|
|
207
210
|
});
|
|
208
211
|
|
|
209
212
|
const extensionHandlers = createExtensionApiHandlers({
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Strip simple HTML-ish tags repeatedly (mitigates incomplete single-pass sanitization). */
|
|
2
|
+
function stripHtmlLikeTagsRepeated(value) {
|
|
3
|
+
let s = String(value);
|
|
4
|
+
let prev;
|
|
5
|
+
do {
|
|
6
|
+
prev = s;
|
|
7
|
+
s = s.replace(/<[^>]*>/g, '');
|
|
8
|
+
} while (s !== prev);
|
|
9
|
+
return s;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether rich-text content is empty after removing tags / Quill placeholders.
|
|
14
|
+
* @param {string} value
|
|
15
|
+
*/
|
|
16
|
+
function isRichTextEmpty(value) {
|
|
17
|
+
if (!value) return true;
|
|
18
|
+
const stripped = stripHtmlLikeTagsRepeated(String(value)).trim();
|
|
19
|
+
const v = String(value).trim();
|
|
20
|
+
return stripped === '' || v === '<p><br></p>' || v === '<p></p>';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { isRichTextEmpty, stripHtmlLikeTagsRepeated };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side HTML sanitization for admin panel Quill/rich-text fields.
|
|
3
|
+
* Intended for XSS mitigation before persistence — not a substitute for safe templating on public pages.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const sanitizeHtml = require('sanitize-html');
|
|
9
|
+
|
|
10
|
+
const RICH_TEXT_SANITIZE_OPTIONS = {
|
|
11
|
+
allowedTags: [
|
|
12
|
+
'p',
|
|
13
|
+
'br',
|
|
14
|
+
'strong',
|
|
15
|
+
'b',
|
|
16
|
+
'em',
|
|
17
|
+
'i',
|
|
18
|
+
'u',
|
|
19
|
+
's',
|
|
20
|
+
'strike',
|
|
21
|
+
'del',
|
|
22
|
+
'h1',
|
|
23
|
+
'h2',
|
|
24
|
+
'h3',
|
|
25
|
+
'h4',
|
|
26
|
+
'h5',
|
|
27
|
+
'h6',
|
|
28
|
+
'blockquote',
|
|
29
|
+
'ul',
|
|
30
|
+
'ol',
|
|
31
|
+
'li',
|
|
32
|
+
'a',
|
|
33
|
+
'span',
|
|
34
|
+
'pre',
|
|
35
|
+
'code',
|
|
36
|
+
],
|
|
37
|
+
allowedAttributes: {
|
|
38
|
+
a: ['href', 'target', 'rel'],
|
|
39
|
+
span: ['class'],
|
|
40
|
+
p: ['class'],
|
|
41
|
+
ol: ['class'],
|
|
42
|
+
ul: ['class'],
|
|
43
|
+
li: ['class'],
|
|
44
|
+
blockquote: ['class'],
|
|
45
|
+
pre: ['class'],
|
|
46
|
+
code: ['class'],
|
|
47
|
+
h1: ['class'],
|
|
48
|
+
h2: ['class'],
|
|
49
|
+
h3: ['class'],
|
|
50
|
+
h4: ['class'],
|
|
51
|
+
h5: ['class'],
|
|
52
|
+
h6: ['class'],
|
|
53
|
+
},
|
|
54
|
+
allowedClasses: {
|
|
55
|
+
span: [/^ql-/],
|
|
56
|
+
p: [/^ql-/],
|
|
57
|
+
ol: [/^ql-/],
|
|
58
|
+
ul: [/^ql-/],
|
|
59
|
+
li: [/^ql-/],
|
|
60
|
+
blockquote: [/^ql-/],
|
|
61
|
+
pre: [/^ql-/],
|
|
62
|
+
code: [/^language-/, /^lang-/, /^ql-/],
|
|
63
|
+
h1: [/^ql-/],
|
|
64
|
+
h2: [/^ql-/],
|
|
65
|
+
h3: [/^ql-/],
|
|
66
|
+
h4: [/^ql-/],
|
|
67
|
+
h5: [/^ql-/],
|
|
68
|
+
h6: [/^ql-/],
|
|
69
|
+
},
|
|
70
|
+
allowedSchemesByTag: {
|
|
71
|
+
a: ['http', 'https', 'mailto', 'tel'],
|
|
72
|
+
},
|
|
73
|
+
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
|
74
|
+
allowProtocolRelative: true,
|
|
75
|
+
transformTags: {
|
|
76
|
+
a(tagName, attribs) {
|
|
77
|
+
const href = (attribs.href || '').trim();
|
|
78
|
+
if (/^javascript:/i.test(href) || /^vbscript:/i.test(href) || /^data:/i.test(href)) {
|
|
79
|
+
delete attribs.href;
|
|
80
|
+
}
|
|
81
|
+
if (attribs.target === '_blank') {
|
|
82
|
+
attribs.rel = 'noopener noreferrer';
|
|
83
|
+
}
|
|
84
|
+
return { tagName, attribs };
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {unknown} input
|
|
91
|
+
* @returns {unknown}
|
|
92
|
+
*/
|
|
93
|
+
function sanitizeRichHtml(input) {
|
|
94
|
+
if (input === null || input === undefined) {
|
|
95
|
+
return input;
|
|
96
|
+
}
|
|
97
|
+
if (typeof input !== 'string') {
|
|
98
|
+
return input;
|
|
99
|
+
}
|
|
100
|
+
return sanitizeHtml(input, RICH_TEXT_SANITIZE_OPTIONS);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
sanitizeRichHtml,
|
|
105
|
+
RICH_TEXT_SANITIZE_OPTIONS,
|
|
106
|
+
};
|
|
@@ -273,23 +273,27 @@ const WidgetRenderers = {
|
|
|
273
273
|
// User stats (for user-management module)
|
|
274
274
|
'user-stats': {
|
|
275
275
|
render: (data) => {
|
|
276
|
-
if (!data) return m('div.text-gray-500', 'No data');
|
|
276
|
+
if (!data) return m('div.text-gray-500.dark:text-slate-400', 'No data');
|
|
277
|
+
if (data.error) {
|
|
278
|
+
return m('div.text-sm.text-red-600.dark:text-red-400', data.error);
|
|
279
|
+
}
|
|
280
|
+
const stat = (v) => (v == null ? '—' : v);
|
|
277
281
|
return m('div.grid.grid-cols-2.gap-4', [
|
|
278
|
-
m('div.text-center.p-3.bg-blue-50.rounded', [
|
|
279
|
-
m('p.text-2xl.font-bold.text-blue-600', data.total),
|
|
280
|
-
m('p.text-xs.text-gray-500', 'Total Users'),
|
|
282
|
+
m('div.text-center.p-3.bg-blue-50.dark:bg-blue-950/40.rounded-lg', [
|
|
283
|
+
m('p.text-2xl.font-bold.text-blue-600.dark:text-blue-400', data.total),
|
|
284
|
+
m('p.text-xs.text-gray-500.dark:text-slate-400', 'Total Users'),
|
|
281
285
|
]),
|
|
282
|
-
m('div.text-center.p-3.bg-green-50.rounded', [
|
|
283
|
-
m('p.text-2xl.font-bold.text-green-600', data.active),
|
|
284
|
-
m('p.text-xs.text-gray-500', 'Active'),
|
|
286
|
+
m('div.text-center.p-3.bg-green-50.dark:bg-green-950/40.rounded-lg', [
|
|
287
|
+
m('p.text-2xl.font-bold.text-green-600.dark:text-green-400', stat(data.active)),
|
|
288
|
+
m('p.text-xs.text-gray-500.dark:text-slate-400', 'Active'),
|
|
285
289
|
]),
|
|
286
|
-
m('div.text-center.p-3.bg-yellow-50.rounded', [
|
|
287
|
-
m('p.text-2xl.font-bold.text-yellow-600', data.admins),
|
|
288
|
-
m('p.text-xs.text-gray-500', 'Admins'),
|
|
290
|
+
m('div.text-center.p-3.bg-yellow-50.dark:bg-amber-950/40.rounded-lg', [
|
|
291
|
+
m('p.text-2xl.font-bold.text-yellow-600.dark:text-amber-400', stat(data.admins)),
|
|
292
|
+
m('p.text-xs.text-gray-500.dark:text-slate-400', 'Admins'),
|
|
289
293
|
]),
|
|
290
|
-
m('div.text-center.p-3.bg-purple-50.rounded', [
|
|
291
|
-
m('p.text-2xl.font-bold.text-purple-600', data.recentUsers),
|
|
292
|
-
m('p.text-xs.text-gray-500', 'This Week'),
|
|
294
|
+
m('div.text-center.p-3.bg-purple-50.dark:bg-purple-950/40.rounded-lg', [
|
|
295
|
+
m('p.text-2xl.font-bold.text-purple-600.dark:text-purple-400', data.recentUsers),
|
|
296
|
+
m('p.text-xs.text-gray-500.dark:text-slate-400', 'This Week'),
|
|
293
297
|
]),
|
|
294
298
|
]);
|
|
295
299
|
},
|
|
@@ -6,6 +6,41 @@
|
|
|
6
6
|
|
|
7
7
|
const { hash, verify } = require('../../../core/auth/hash');
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* DBs like SQLite store booleans as 0/1; `repo.count({ active: true })` can return 0 while rows are "active".
|
|
11
|
+
* Postgres/MySQL use real booleans — `true` is correct.
|
|
12
|
+
*/
|
|
13
|
+
function truthyBooleanForDb(db) {
|
|
14
|
+
try {
|
|
15
|
+
const client = db?.knex?.client?.config?.client;
|
|
16
|
+
if (client === 'sqlite3' || client === 'better-sqlite3') return 1;
|
|
17
|
+
} catch (_) {}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* True when the physical table has the column (handles schema vs migration drift).
|
|
23
|
+
*/
|
|
24
|
+
async function physicalColumnExists(db, table, column) {
|
|
25
|
+
if (!db?.knex?.schema || !table || !column) return false;
|
|
26
|
+
try {
|
|
27
|
+
return await db.knex.schema.hasColumn(table, column);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `userManagement: { model: 'Member' }` is the public API; `modelName` is also accepted. */
|
|
34
|
+
function resolveUserManagementModelName(config = {}) {
|
|
35
|
+
if (config.modelName != null && String(config.modelName).trim() !== '') {
|
|
36
|
+
return String(config.modelName).trim();
|
|
37
|
+
}
|
|
38
|
+
if (config.model != null && String(config.model).trim() !== '') {
|
|
39
|
+
return String(config.model).trim();
|
|
40
|
+
}
|
|
41
|
+
return 'User';
|
|
42
|
+
}
|
|
43
|
+
|
|
9
44
|
/**
|
|
10
45
|
* Register user management in admin panel
|
|
11
46
|
* @param {Object} options - Options
|
|
@@ -18,12 +53,13 @@ function registerUserManagement(options) {
|
|
|
18
53
|
const { registry, db, auth, config = {} } = options;
|
|
19
54
|
|
|
20
55
|
const {
|
|
21
|
-
modelName = 'User',
|
|
22
56
|
fields = {},
|
|
23
57
|
roles = ['user', 'admin'],
|
|
24
58
|
passwordMinLength = 8,
|
|
25
59
|
} = config;
|
|
26
60
|
|
|
61
|
+
const modelName = resolveUserManagementModelName(config);
|
|
62
|
+
|
|
27
63
|
const fieldMap = {
|
|
28
64
|
email: 'email',
|
|
29
65
|
password: 'password',
|
|
@@ -49,11 +85,15 @@ function registerUserManagement(options) {
|
|
|
49
85
|
order: 100,
|
|
50
86
|
});
|
|
51
87
|
|
|
88
|
+
// Sidebar targets ORM CRUD routes directly (avoids Mithril onmatch redirect races on /users/new).
|
|
89
|
+
const userModelListPath = '/models/' + encodeURIComponent(modelName);
|
|
90
|
+
const userModelNewPath = userModelListPath + '/new';
|
|
91
|
+
|
|
52
92
|
// Register menu items
|
|
53
93
|
registry.registerMenuItem({
|
|
54
94
|
id: 'user-list',
|
|
55
95
|
label: 'All Users',
|
|
56
|
-
path:
|
|
96
|
+
path: userModelListPath,
|
|
57
97
|
icon: 'users',
|
|
58
98
|
group: 'users',
|
|
59
99
|
order: 1,
|
|
@@ -62,7 +102,7 @@ function registerUserManagement(options) {
|
|
|
62
102
|
registry.registerMenuItem({
|
|
63
103
|
id: 'user-create',
|
|
64
104
|
label: 'Add User',
|
|
65
|
-
path:
|
|
105
|
+
path: userModelNewPath,
|
|
66
106
|
icon: 'user-plus',
|
|
67
107
|
group: 'users',
|
|
68
108
|
order: 2,
|
|
@@ -87,26 +127,58 @@ function registerUserManagement(options) {
|
|
|
87
127
|
dataLoader: async ({ db }) => {
|
|
88
128
|
try {
|
|
89
129
|
const repo = db.getRepository(modelName);
|
|
130
|
+
const model = repo.model;
|
|
131
|
+
const cols = model.columns;
|
|
132
|
+
const table = model.table;
|
|
90
133
|
const total = await repo.count();
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
.count();
|
|
134
|
+
|
|
135
|
+
const activeCol = fieldMap.active;
|
|
136
|
+
const hasActiveCol =
|
|
137
|
+
cols?.has(activeCol) && (await physicalColumnExists(db, table, activeCol));
|
|
138
|
+
let active = null;
|
|
139
|
+
let inactive = null;
|
|
140
|
+
if (hasActiveCol) {
|
|
141
|
+
const activeEq = truthyBooleanForDb(db);
|
|
142
|
+
active = await repo.query().where(activeCol, activeEq).count();
|
|
143
|
+
inactive = Math.max(0, total - active);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const roleCol = fieldMap.role;
|
|
147
|
+
let admins = null;
|
|
148
|
+
const hasRoleCol =
|
|
149
|
+
cols?.has(roleCol) && (await physicalColumnExists(db, table, roleCol));
|
|
150
|
+
if (hasRoleCol) {
|
|
151
|
+
admins = await repo.count({ [roleCol]: 'admin' });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let recentUsers = 0;
|
|
155
|
+
const createdCol = fieldMap.createdAt;
|
|
156
|
+
const hasCreatedCol =
|
|
157
|
+
cols?.has(createdCol) && (await physicalColumnExists(db, table, createdCol));
|
|
158
|
+
if (hasCreatedCol) {
|
|
159
|
+
const weekAgo = new Date();
|
|
160
|
+
weekAgo.setDate(weekAgo.getDate() - 7);
|
|
161
|
+
recentUsers = await repo.query()
|
|
162
|
+
.where(createdCol, '>=', weekAgo.toISOString())
|
|
163
|
+
.count();
|
|
164
|
+
}
|
|
100
165
|
|
|
101
166
|
return {
|
|
102
167
|
total,
|
|
103
168
|
active,
|
|
104
|
-
inactive
|
|
169
|
+
inactive,
|
|
105
170
|
admins,
|
|
106
171
|
recentUsers,
|
|
107
172
|
};
|
|
108
173
|
} catch (e) {
|
|
109
|
-
return {
|
|
174
|
+
return {
|
|
175
|
+
total: 0,
|
|
176
|
+
active: null,
|
|
177
|
+
inactive: null,
|
|
178
|
+
admins: null,
|
|
179
|
+
recentUsers: 0,
|
|
180
|
+
error: e.message,
|
|
181
|
+
};
|
|
110
182
|
}
|
|
111
183
|
},
|
|
112
184
|
});
|
|
@@ -234,10 +306,8 @@ function registerUserManagement(options) {
|
|
|
234
306
|
*/
|
|
235
307
|
function createUserManagementApiHandlers(options) {
|
|
236
308
|
const { db, config, auth } = options;
|
|
237
|
-
const {
|
|
238
|
-
|
|
239
|
-
fields = {},
|
|
240
|
-
} = config;
|
|
309
|
+
const { fields = {} } = config;
|
|
310
|
+
const modelName = resolveUserManagementModelName(config);
|
|
241
311
|
|
|
242
312
|
const fieldMap = {
|
|
243
313
|
email: 'email',
|
|
@@ -254,6 +324,8 @@ function createUserManagementApiHandlers(options) {
|
|
|
254
324
|
async function listUsers(req, res) {
|
|
255
325
|
try {
|
|
256
326
|
const repo = db.getRepository(modelName);
|
|
327
|
+
const model = repo.model;
|
|
328
|
+
const table = model.table;
|
|
257
329
|
const page = parseInt(req.query.page) || 1;
|
|
258
330
|
const perPage = parseInt(req.query.perPage) || 20;
|
|
259
331
|
const search = req.query.search;
|
|
@@ -262,21 +334,40 @@ function createUserManagementApiHandlers(options) {
|
|
|
262
334
|
|
|
263
335
|
let query = repo.query();
|
|
264
336
|
|
|
265
|
-
// Search
|
|
337
|
+
// Search (only columns that exist on the physical table)
|
|
266
338
|
if (search) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
339
|
+
const hasEmail = await physicalColumnExists(db, table, fieldMap.email);
|
|
340
|
+
const hasName = await physicalColumnExists(db, table, fieldMap.name);
|
|
341
|
+
if (hasEmail || hasName) {
|
|
342
|
+
query = query.where(function () {
|
|
343
|
+
if (hasEmail && hasName) {
|
|
344
|
+
this.where(fieldMap.email, 'like', `%${search}%`).orWhere(
|
|
345
|
+
fieldMap.name,
|
|
346
|
+
'like',
|
|
347
|
+
`%${search}%`
|
|
348
|
+
);
|
|
349
|
+
} else if (hasEmail) {
|
|
350
|
+
this.where(fieldMap.email, 'like', `%${search}%`);
|
|
351
|
+
} else {
|
|
352
|
+
this.where(fieldMap.name, 'like', `%${search}%`);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
271
356
|
}
|
|
272
357
|
|
|
273
|
-
|
|
274
|
-
|
|
358
|
+
if (
|
|
359
|
+
role &&
|
|
360
|
+
model.columns?.has(fieldMap.role) &&
|
|
361
|
+
(await physicalColumnExists(db, table, fieldMap.role))
|
|
362
|
+
) {
|
|
275
363
|
query = query.where(fieldMap.role, role);
|
|
276
364
|
}
|
|
277
365
|
|
|
278
|
-
|
|
279
|
-
|
|
366
|
+
if (
|
|
367
|
+
active !== undefined &&
|
|
368
|
+
model.columns?.has(fieldMap.active) &&
|
|
369
|
+
(await physicalColumnExists(db, table, fieldMap.active))
|
|
370
|
+
) {
|
|
280
371
|
query = query.where(fieldMap.active, active === 'true');
|
|
281
372
|
}
|
|
282
373
|
|
|
@@ -30,6 +30,9 @@ async function buildXlsxBuffer(model, records) {
|
|
|
30
30
|
const rowValues = columns.map((c) => {
|
|
31
31
|
let v = rec[c];
|
|
32
32
|
if (v === null || v === undefined) return '';
|
|
33
|
+
if (typeof v === 'bigint') return v.toString();
|
|
34
|
+
if (v instanceof Date) return v.toISOString();
|
|
35
|
+
if (Buffer.isBuffer(v)) return v.toString('base64');
|
|
33
36
|
if (typeof v === 'object') v = JSON.stringify(v);
|
|
34
37
|
if (typeof v === 'string' && /^[=+\-@]/.test(v)) return ` ${v}`;
|
|
35
38
|
return v;
|
|
@@ -24,6 +24,10 @@ async function resolveExportRecords(db, modelName, req) {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
const trashedOnly =
|
|
28
|
+
body.trashed === 'only' ||
|
|
29
|
+
req.query.trashed === 'only';
|
|
30
|
+
|
|
27
31
|
let idList = null;
|
|
28
32
|
if (req.body?.ids && Array.isArray(req.body.ids)) {
|
|
29
33
|
idList = req.body.ids;
|
|
@@ -45,14 +49,26 @@ async function resolveExportRecords(db, modelName, req) {
|
|
|
45
49
|
const repo = db.getRepository(model.name);
|
|
46
50
|
let records;
|
|
47
51
|
|
|
52
|
+
const filterOpts =
|
|
53
|
+
trashedOnly && model.scopes?.softDelete ? { onlyTrashed: true } : {};
|
|
54
|
+
|
|
48
55
|
if (selectAll) {
|
|
49
|
-
const query = buildFilteredQuery(repo, filters);
|
|
56
|
+
const query = buildFilteredQuery(repo, filters, filterOpts);
|
|
50
57
|
records = await query.list();
|
|
51
58
|
} else if (idList && idList.length > 0) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
if (trashedOnly && model.scopes?.softDelete) {
|
|
60
|
+
const pk = model.primaryKey || 'id';
|
|
61
|
+
records = await repo
|
|
62
|
+
.query()
|
|
63
|
+
.onlyTrashed()
|
|
64
|
+
.whereIn(pk, idList)
|
|
65
|
+
.list();
|
|
66
|
+
} else {
|
|
67
|
+
records = [];
|
|
68
|
+
for (const id of idList) {
|
|
69
|
+
const record = await repo.findById(id);
|
|
70
|
+
if (record) records.push(record);
|
|
71
|
+
}
|
|
56
72
|
}
|
|
57
73
|
} else {
|
|
58
74
|
records = await repo.findAll();
|
package/plugins/index.js
CHANGED
|
@@ -19,6 +19,8 @@ const ormCacheAdminPlugin = require('./orm-cache-admin');
|
|
|
19
19
|
const { uploadPlugin, createLocalFileProvider } = require('./upload');
|
|
20
20
|
/** Register after adminPanelPlugin (same db + adminPath) for session and routes. */
|
|
21
21
|
const { dataExchangePlugin } = require('./data-exchange');
|
|
22
|
+
const { redirectPlugin } = require('./redirect');
|
|
23
|
+
const { rateLimitPlugin } = require('./rate-limit');
|
|
22
24
|
|
|
23
25
|
module.exports = {
|
|
24
26
|
sitemapPlugin,
|
|
@@ -37,5 +39,7 @@ module.exports = {
|
|
|
37
39
|
uploadPlugin,
|
|
38
40
|
createLocalFileProvider,
|
|
39
41
|
dataExchangePlugin,
|
|
42
|
+
redirectPlugin,
|
|
43
|
+
rateLimitPlugin,
|
|
40
44
|
};
|
|
41
45
|
|