webspresso 0.0.74 → 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 +41 -3
- package/bin/commands/orm-map.js +139 -0
- package/bin/commands/skill.js +22 -8
- 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 +2 -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/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 +3 -2
- package/plugins/admin-panel/modules/user-management.js +90 -20
- 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/swagger.js +2 -1
- package/plugins/upload/local-file-provider.js +6 -2
- package/src/file-router.js +191 -50
- package/src/njk-frontmatter.js +156 -0
- package/src/plugin-manager.js +4 -2
- package/src/server.js +26 -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 -278
|
@@ -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
|
+
};
|
|
@@ -277,17 +277,18 @@ const WidgetRenderers = {
|
|
|
277
277
|
if (data.error) {
|
|
278
278
|
return m('div.text-sm.text-red-600.dark:text-red-400', data.error);
|
|
279
279
|
}
|
|
280
|
+
const stat = (v) => (v == null ? '—' : v);
|
|
280
281
|
return m('div.grid.grid-cols-2.gap-4', [
|
|
281
282
|
m('div.text-center.p-3.bg-blue-50.dark:bg-blue-950/40.rounded-lg', [
|
|
282
283
|
m('p.text-2xl.font-bold.text-blue-600.dark:text-blue-400', data.total),
|
|
283
284
|
m('p.text-xs.text-gray-500.dark:text-slate-400', 'Total Users'),
|
|
284
285
|
]),
|
|
285
286
|
m('div.text-center.p-3.bg-green-50.dark:bg-green-950/40.rounded-lg', [
|
|
286
|
-
m('p.text-2xl.font-bold.text-green-600.dark:text-green-400', data.active),
|
|
287
|
+
m('p.text-2xl.font-bold.text-green-600.dark:text-green-400', stat(data.active)),
|
|
287
288
|
m('p.text-xs.text-gray-500.dark:text-slate-400', 'Active'),
|
|
288
289
|
]),
|
|
289
290
|
m('div.text-center.p-3.bg-yellow-50.dark:bg-amber-950/40.rounded-lg', [
|
|
290
|
-
m('p.text-2xl.font-bold.text-yellow-600.dark:text-amber-400', data.admins),
|
|
291
|
+
m('p.text-2xl.font-bold.text-yellow-600.dark:text-amber-400', stat(data.admins)),
|
|
291
292
|
m('p.text-xs.text-gray-500.dark:text-slate-400', 'Admins'),
|
|
292
293
|
]),
|
|
293
294
|
m('div.text-center.p-3.bg-purple-50.dark:bg-purple-950/40.rounded-lg', [
|
|
@@ -18,6 +18,29 @@ function truthyBooleanForDb(db) {
|
|
|
18
18
|
return true;
|
|
19
19
|
}
|
|
20
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
|
+
|
|
21
44
|
/**
|
|
22
45
|
* Register user management in admin panel
|
|
23
46
|
* @param {Object} options - Options
|
|
@@ -30,12 +53,13 @@ function registerUserManagement(options) {
|
|
|
30
53
|
const { registry, db, auth, config = {} } = options;
|
|
31
54
|
|
|
32
55
|
const {
|
|
33
|
-
modelName = 'User',
|
|
34
56
|
fields = {},
|
|
35
57
|
roles = ['user', 'admin'],
|
|
36
58
|
passwordMinLength = 8,
|
|
37
59
|
} = config;
|
|
38
60
|
|
|
61
|
+
const modelName = resolveUserManagementModelName(config);
|
|
62
|
+
|
|
39
63
|
const fieldMap = {
|
|
40
64
|
email: 'email',
|
|
41
65
|
password: 'password',
|
|
@@ -104,14 +128,34 @@ function registerUserManagement(options) {
|
|
|
104
128
|
try {
|
|
105
129
|
const repo = db.getRepository(modelName);
|
|
106
130
|
const model = repo.model;
|
|
131
|
+
const cols = model.columns;
|
|
132
|
+
const table = model.table;
|
|
107
133
|
const total = await repo.count();
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
const
|
|
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
|
+
}
|
|
111
153
|
|
|
112
154
|
let recentUsers = 0;
|
|
113
155
|
const createdCol = fieldMap.createdAt;
|
|
114
|
-
|
|
156
|
+
const hasCreatedCol =
|
|
157
|
+
cols?.has(createdCol) && (await physicalColumnExists(db, table, createdCol));
|
|
158
|
+
if (hasCreatedCol) {
|
|
115
159
|
const weekAgo = new Date();
|
|
116
160
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
|
117
161
|
recentUsers = await repo.query()
|
|
@@ -122,12 +166,19 @@ function registerUserManagement(options) {
|
|
|
122
166
|
return {
|
|
123
167
|
total,
|
|
124
168
|
active,
|
|
125
|
-
inactive
|
|
169
|
+
inactive,
|
|
126
170
|
admins,
|
|
127
171
|
recentUsers,
|
|
128
172
|
};
|
|
129
173
|
} catch (e) {
|
|
130
|
-
return {
|
|
174
|
+
return {
|
|
175
|
+
total: 0,
|
|
176
|
+
active: null,
|
|
177
|
+
inactive: null,
|
|
178
|
+
admins: null,
|
|
179
|
+
recentUsers: 0,
|
|
180
|
+
error: e.message,
|
|
181
|
+
};
|
|
131
182
|
}
|
|
132
183
|
},
|
|
133
184
|
});
|
|
@@ -255,10 +306,8 @@ function registerUserManagement(options) {
|
|
|
255
306
|
*/
|
|
256
307
|
function createUserManagementApiHandlers(options) {
|
|
257
308
|
const { db, config, auth } = options;
|
|
258
|
-
const {
|
|
259
|
-
|
|
260
|
-
fields = {},
|
|
261
|
-
} = config;
|
|
309
|
+
const { fields = {} } = config;
|
|
310
|
+
const modelName = resolveUserManagementModelName(config);
|
|
262
311
|
|
|
263
312
|
const fieldMap = {
|
|
264
313
|
email: 'email',
|
|
@@ -275,6 +324,8 @@ function createUserManagementApiHandlers(options) {
|
|
|
275
324
|
async function listUsers(req, res) {
|
|
276
325
|
try {
|
|
277
326
|
const repo = db.getRepository(modelName);
|
|
327
|
+
const model = repo.model;
|
|
328
|
+
const table = model.table;
|
|
278
329
|
const page = parseInt(req.query.page) || 1;
|
|
279
330
|
const perPage = parseInt(req.query.perPage) || 20;
|
|
280
331
|
const search = req.query.search;
|
|
@@ -283,21 +334,40 @@ function createUserManagementApiHandlers(options) {
|
|
|
283
334
|
|
|
284
335
|
let query = repo.query();
|
|
285
336
|
|
|
286
|
-
// Search
|
|
337
|
+
// Search (only columns that exist on the physical table)
|
|
287
338
|
if (search) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
+
}
|
|
292
356
|
}
|
|
293
357
|
|
|
294
|
-
|
|
295
|
-
|
|
358
|
+
if (
|
|
359
|
+
role &&
|
|
360
|
+
model.columns?.has(fieldMap.role) &&
|
|
361
|
+
(await physicalColumnExists(db, table, fieldMap.role))
|
|
362
|
+
) {
|
|
296
363
|
query = query.where(fieldMap.role, role);
|
|
297
364
|
}
|
|
298
365
|
|
|
299
|
-
|
|
300
|
-
|
|
366
|
+
if (
|
|
367
|
+
active !== undefined &&
|
|
368
|
+
model.columns?.has(fieldMap.active) &&
|
|
369
|
+
(await physicalColumnExists(db, table, fieldMap.active))
|
|
370
|
+
) {
|
|
301
371
|
query = query.where(fieldMap.active, active === 'true');
|
|
302
372
|
}
|
|
303
373
|
|
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
|
|