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.
Files changed (65) hide show
  1. package/README.md +44 -4
  2. package/bin/commands/orm-map.js +139 -0
  3. package/bin/commands/skill.js +22 -8
  4. package/bin/commands/upgrade.js +146 -0
  5. package/bin/utils/orm-map-html.js +689 -0
  6. package/bin/utils/orm-map-load.js +85 -0
  7. package/bin/utils/orm-map-snapshot.js +179 -0
  8. package/bin/utils/resolve-webspresso-orm.js +23 -0
  9. package/bin/webspresso.js +4 -0
  10. package/core/auth/manager.js +14 -1
  11. package/core/kernel/app.js +96 -0
  12. package/core/kernel/base-repository.js +143 -0
  13. package/core/kernel/events.js +101 -0
  14. package/core/kernel/flow.js +22 -0
  15. package/core/kernel/index.js +17 -0
  16. package/core/kernel/plugin.js +23 -0
  17. package/core/kernel/plugins/sample-seo.js +26 -0
  18. package/core/kernel/run-demo.js +58 -0
  19. package/core/kernel/view.js +167 -0
  20. package/core/openapi/build-from-api-routes.js +8 -2
  21. package/core/orm/model.js +3 -1
  22. package/core/url-path-normalize.js +30 -0
  23. package/index.d.ts +168 -1
  24. package/index.js +20 -2
  25. package/package.json +11 -1
  26. package/plugins/admin-panel/api.js +43 -15
  27. package/plugins/admin-panel/app.js +109 -0
  28. package/plugins/admin-panel/client/README.md +39 -0
  29. package/plugins/admin-panel/client/load-parts.js +74 -0
  30. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  31. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  32. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  33. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  34. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  35. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  36. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  37. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  38. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  39. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  40. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  41. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  42. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  43. package/plugins/admin-panel/components.js +4 -2640
  44. package/plugins/admin-panel/core/api-extensions.js +100 -10
  45. package/plugins/admin-panel/index.js +3 -0
  46. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  47. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  48. package/plugins/admin-panel/modules/dashboard.js +17 -13
  49. package/plugins/admin-panel/modules/user-management.js +118 -27
  50. package/plugins/data-exchange/export-xlsx.js +3 -0
  51. package/plugins/data-exchange/record-selection.js +21 -5
  52. package/plugins/index.js +4 -0
  53. package/plugins/rate-limit/index.js +178 -0
  54. package/plugins/redirect/index.js +204 -0
  55. package/plugins/rest-resources/index.js +2 -1
  56. package/plugins/site-analytics/admin-component.js +88 -78
  57. package/plugins/swagger.js +2 -1
  58. package/plugins/upload/local-file-provider.js +6 -2
  59. package/src/file-router.js +270 -53
  60. package/src/njk-frontmatter.js +156 -0
  61. package/src/plugin-manager.js +4 -2
  62. package/src/server.js +28 -9
  63. package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
  64. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  65. 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 || (filter.value === '' && !filter.from && !filter.to)) continue;
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 (for enum/boolean fields)
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
- if (!isEnum && !isBoolean) {
292
- return res.status(400).json({ error: `Field "${field}" is not an enum or boolean type` });
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 and boolean fields)
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: '/users',
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: '/users/new',
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
- const active = await repo.count({ [fieldMap.active]: true });
92
- const admins = await repo.count({ [fieldMap.role]: 'admin' });
93
-
94
- // Recent users (last 7 days)
95
- const weekAgo = new Date();
96
- weekAgo.setDate(weekAgo.getDate() - 7);
97
- const recentUsers = await repo.query()
98
- .where(fieldMap.createdAt, '>=', weekAgo.toISOString())
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: total - active,
169
+ inactive,
105
170
  admins,
106
171
  recentUsers,
107
172
  };
108
173
  } catch (e) {
109
- return { total: 0, active: 0, inactive: 0, admins: 0, recentUsers: 0, error: e.message };
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
- modelName = 'User',
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
- query = query.where(function() {
268
- this.where(fieldMap.email, 'like', `%${search}%`)
269
- .orWhere(fieldMap.name, 'like', `%${search}%`);
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
- // Role filter
274
- if (role) {
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
- // Active filter
279
- if (active !== undefined) {
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
- records = [];
53
- for (const id of idList) {
54
- const record = await repo.findById(id);
55
- if (record) records.push(record);
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