webspresso 0.0.96 → 0.0.98

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 (121) hide show
  1. package/.agents/ADMIN.md +153 -5
  2. package/.agents/ARCHITECTURE.md +10 -0
  3. package/.agents/CLI.md +195 -1
  4. package/.agents/PLUGINS.md +153 -9
  5. package/.agents/QUEUE.md +43 -0
  6. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
  7. package/.agents/skills/webspresso-framework/SKILL.md +56 -0
  8. package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
  9. package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
  10. package/.agents/skills/webspresso-services/SKILL.md +101 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/admin-password.js +2 -1
  14. package/bin/commands/admin-setup.js +1 -1
  15. package/bin/commands/agents-init.js +51 -11
  16. package/bin/commands/analytics-prune.js +57 -0
  17. package/bin/commands/api.js +207 -15
  18. package/bin/commands/audit-prune.js +1 -1
  19. package/bin/commands/build.js +99 -0
  20. package/bin/commands/dev.js +64 -35
  21. package/bin/commands/doctor.js +26 -0
  22. package/bin/commands/email-prune.js +47 -0
  23. package/bin/commands/favicon-generate.js +1 -1
  24. package/bin/commands/make-job.js +86 -0
  25. package/bin/commands/make-model.js +250 -0
  26. package/bin/commands/new.js +45 -28
  27. package/bin/commands/openapi.js +102 -0
  28. package/bin/commands/orm-map.js +5 -4
  29. package/bin/commands/page.js +6 -3
  30. package/bin/commands/polar-migrate.js +1 -1
  31. package/bin/commands/queue-prune.js +59 -0
  32. package/bin/commands/routes.js +52 -0
  33. package/bin/commands/seed.js +1 -1
  34. package/bin/commands/service.js +6 -3
  35. package/bin/commands/typegen.js +106 -0
  36. package/bin/commands/upload-prune.js +79 -0
  37. package/bin/webspresso.js +22 -0
  38. package/core/applySchema.js +13 -6
  39. package/core/auth/hash.js +31 -17
  40. package/core/compression/middleware.js +23 -17
  41. package/core/openapi/build-from-api-routes.js +160 -29
  42. package/core/orm/cache/fingerprint.js +15 -1
  43. package/core/orm/cache/memory-provider.js +27 -2
  44. package/core/orm/index.js +12 -7
  45. package/core/orm/model.js +49 -7
  46. package/core/queue/adapters/database.js +94 -0
  47. package/core/queue/adapters/memory.js +62 -0
  48. package/core/queue/adapters/redis.js +80 -0
  49. package/core/queue/manager.js +123 -0
  50. package/core/ssr/stream.js +11 -0
  51. package/index.d.ts +660 -10
  52. package/index.js +44 -96
  53. package/package.json +32 -12
  54. package/plugins/admin-panel/api.js +279 -17
  55. package/plugins/admin-panel/app.js +124 -31
  56. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  57. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  58. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  59. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  60. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  61. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  62. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  63. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  64. package/plugins/admin-panel/core/admin-module.js +13 -7
  65. package/plugins/admin-panel/core/api-extensions.js +34 -2
  66. package/plugins/admin-panel/core/registry.js +96 -16
  67. package/plugins/admin-panel/index.js +96 -0
  68. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  69. package/plugins/admin-panel/modules/admin-users.js +16 -1
  70. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  71. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  72. package/plugins/admin-panel/modules/dashboard.js +283 -0
  73. package/plugins/admin-panel/modules/menu.js +56 -18
  74. package/plugins/admin-panel/modules/user-management.js +24 -6
  75. package/plugins/audit-log/admin-component.js +11 -0
  76. package/plugins/audit-log/api-handlers.js +34 -0
  77. package/plugins/audit-log/index.js +129 -3
  78. package/plugins/audit-log/purge.js +60 -10
  79. package/plugins/basic-auth/index.js +3 -1
  80. package/plugins/content/client/inline-edit.js +12 -12
  81. package/plugins/cors.js +6 -7
  82. package/plugins/dashboard/app.js +152 -27
  83. package/plugins/dashboard/index.js +143 -39
  84. package/plugins/dashboard/styles.js +78 -0
  85. package/plugins/data-exchange/export-xlsx.js +9 -2
  86. package/plugins/data-exchange/import.js +9 -2
  87. package/plugins/data-exchange/index.js +6 -3
  88. package/plugins/email/index.js +132 -2
  89. package/plugins/email/purge.js +60 -10
  90. package/plugins/file-manager/core.js +1 -1
  91. package/plugins/file-manager/security.js +33 -23
  92. package/plugins/index.js +49 -87
  93. package/plugins/mcp/transports/sse.js +4 -2
  94. package/plugins/orm-cache-admin/index.js +4 -2
  95. package/plugins/queue/index.js +11 -0
  96. package/plugins/schema-explorer/admin-component.js +714 -0
  97. package/plugins/schema-explorer/relations-graph.js +205 -0
  98. package/plugins/schema-explorer.js +248 -69
  99. package/plugins/site-analytics/admin-component.js +30 -8
  100. package/plugins/site-analytics/api-handlers.js +42 -0
  101. package/plugins/site-analytics/index.js +147 -2
  102. package/plugins/site-analytics/purge.js +168 -0
  103. package/plugins/swagger.js +261 -13
  104. package/plugins/upload/index.js +26 -0
  105. package/plugins/upload/orphan-pruner.js +292 -0
  106. package/src/api/api-loader.js +3 -1
  107. package/src/bundle/index.js +517 -0
  108. package/src/bundle/manifest.js +165 -0
  109. package/src/dev/banner.js +296 -0
  110. package/src/dev/colors.js +70 -0
  111. package/src/dev/index.js +62 -0
  112. package/src/dev/profiler.js +354 -0
  113. package/src/dev/typegen.js +497 -0
  114. package/src/dev-reload/index.js +611 -0
  115. package/src/file-router.js +213 -63
  116. package/src/helpers.js +958 -98
  117. package/src/njk-frontmatter.js +12 -0
  118. package/src/pages/page-loader.js +61 -11
  119. package/src/server.js +629 -63
  120. package/src/services/discovery.js +6 -1
  121. package/src/services/index.js +6 -13
@@ -8,4 +8,27 @@ window.__ADMIN_COMPONENTS__ = {
8
8
  RecordForm,
9
9
  api,
10
10
  state,
11
+ notify: typeof window !== 'undefined' ? window.notify : undefined,
12
+ confirmModal: typeof window !== 'undefined' ? window.confirmModal : undefined,
13
+ toastState: typeof window !== 'undefined' ? window.toastState : undefined,
14
+ confirmState: typeof window !== 'undefined' ? window.confirmState : undefined,
15
+ AdminUI: typeof window !== 'undefined' ? window.AdminUI : undefined,
16
+ Admin: typeof window !== 'undefined' ? window.Admin : undefined,
17
+ ToastContainer: typeof window !== 'undefined' ? window.ToastContainer : undefined,
18
+ ConfirmModalContainer: typeof window !== 'undefined' ? window.ConfirmModalContainer : undefined,
11
19
  };
20
+
21
+ if (typeof window !== 'undefined') {
22
+ window.Admin = window.Admin || {};
23
+ window.Admin.api = api;
24
+ window.Admin.state = state;
25
+ if (typeof window.AdminUI !== 'undefined') {
26
+ window.Admin.ui = window.AdminUI;
27
+ }
28
+ if (typeof window.notify !== 'undefined') {
29
+ window.Admin.toast = window.notify;
30
+ }
31
+ if (typeof window.confirmModal !== 'undefined') {
32
+ window.Admin.confirm = window.confirmModal;
33
+ }
34
+ }
@@ -193,19 +193,22 @@ function registerModule(config, deps) {
193
193
  menuList = menuList.concat(dirMenuItems);
194
194
  }
195
195
 
196
+ const moduleTitle = config.title;
197
+
196
198
  for (const p of pagesList) {
197
199
  if (p.menu && !menuList.some(m => m.id === p.id)) {
200
+ const pageLabel = p.title || moduleTitle || p.id;
198
201
  if (p.menu === true) {
199
202
  menuList.push({
200
203
  id: p.id,
201
- label: p.title,
204
+ label: pageLabel,
202
205
  path: p.path,
203
206
  icon: p.icon,
204
207
  });
205
208
  } else if (typeof p.menu === 'object') {
206
209
  menuList.push({
207
210
  id: p.id,
208
- label: p.title,
211
+ label: pageLabel,
209
212
  path: p.path,
210
213
  icon: p.icon,
211
214
  ...p.menu,
@@ -215,7 +218,7 @@ function registerModule(config, deps) {
215
218
  }
216
219
 
217
220
  if (pagesList.length > 0) {
218
- registerPages(config.id, pagesList, { registry, adminPath, ctx, optionalAuth, serveAdminPanel });
221
+ registerPages(config.id, pagesList, { registry, adminPath, ctx, optionalAuth, serveAdminPanel }, moduleTitle);
219
222
  }
220
223
 
221
224
  if (menuList.length > 0) {
@@ -246,7 +249,7 @@ function registerPageDir(dirPath, deps) {
246
249
  }
247
250
  }
248
251
 
249
- function registerPages(moduleId, pages, deps) {
252
+ function registerPages(moduleId, pages, deps, moduleTitle) {
250
253
  if (!Array.isArray(pages)) {
251
254
  throw new Error(`Module "${moduleId}": pages must be an array`);
252
255
  }
@@ -257,8 +260,9 @@ function registerPages(moduleId, pages, deps) {
257
260
  const pageId = page.id || moduleId;
258
261
 
259
262
  let htmlContent = page.html || null;
263
+ let resolvedHtmlPath = null;
260
264
  if (page.htmlFile) {
261
- const resolvedHtmlPath = path.resolve(page.htmlFile);
265
+ resolvedHtmlPath = path.resolve(page.htmlFile);
262
266
  if (!fs.existsSync(resolvedHtmlPath)) {
263
267
  throw new Error(`Module "${moduleId}", page "${pageId}": htmlFile not found at ${page.htmlFile}`);
264
268
  }
@@ -266,7 +270,8 @@ function registerPages(moduleId, pages, deps) {
266
270
  }
267
271
 
268
272
  registry.registerPage(pageId, {
269
- title: page.title,
273
+ title: page.title || moduleTitle,
274
+ documentTitle: page.documentTitle,
270
275
  path: page.path,
271
276
  icon: page.icon,
272
277
  description: page.description,
@@ -275,6 +280,7 @@ function registerPages(moduleId, pages, deps) {
275
280
  iframe: page.iframe,
276
281
  url: page.url || page.iframeUrl || undefined,
277
282
  html: htmlContent || undefined,
283
+ htmlFile: resolvedHtmlPath || (page.htmlFile ? path.resolve(page.htmlFile) : undefined),
278
284
  });
279
285
 
280
286
  if (page.componentFile) {
@@ -284,7 +290,7 @@ function registerPages(moduleId, pages, deps) {
284
290
  }
285
291
  const rawCode = fs.readFileSync(resolvedPath, 'utf8');
286
292
  const formattedCode = formatClientComponent(pageId, rawCode);
287
- registry.registerClientComponent(pageId, formattedCode);
293
+ registry.registerClientComponent(pageId, formattedCode, resolvedPath);
288
294
  } else if (page.component) {
289
295
  const formattedCode = formatClientComponent(pageId, page.component);
290
296
  registry.registerClientComponent(pageId, formattedCode);
@@ -24,6 +24,10 @@ let npmVersionCache = {
24
24
  };
25
25
 
26
26
  function fetchLatestNpmVersion(force = false) {
27
+ if ((process.env.NODE_ENV === 'test' || process.env.VITEST) && !force) {
28
+ return Promise.resolve(npmVersionCache.latestVersion || getWebspressoVersion());
29
+ }
30
+
27
31
  const now = Date.now();
28
32
  if (!force && npmVersionCache.latestVersion && (now - npmVersionCache.checkedAt < 30 * 60 * 1000)) {
29
33
  return Promise.resolve(npmVersionCache.latestVersion);
@@ -336,12 +340,24 @@ function createExtensionApiHandlers(options) {
336
340
  }
337
341
 
338
342
  // Execute action
339
- const result = await action.handler(record, modelName, {
343
+ const context = {
340
344
  db,
341
345
  req,
342
346
  user: req.session?.adminUser,
343
347
  body: req.body,
348
+ model: modelName,
349
+ modelName,
350
+ name: modelName,
351
+ };
352
+ const modelArg = Object.assign(new String(modelName), context, {
353
+ name: modelName,
354
+ modelName,
355
+ model: modelName,
356
+ valueOf: () => modelName,
357
+ toString: () => modelName,
358
+ [Symbol.toPrimitive]: () => modelName,
344
359
  });
360
+ const result = await action.handler(record, modelArg, context);
345
361
 
346
362
  res.json({ success: true, result });
347
363
  } catch (error) {
@@ -414,12 +430,24 @@ function createExtensionApiHandlers(options) {
414
430
  }
415
431
 
416
432
  // Execute action
417
- const result = await action.handler(records, modelName, {
433
+ const context = {
418
434
  db,
419
435
  req,
420
436
  user: req.session?.adminUser,
421
437
  body: req.body,
438
+ model: modelName,
439
+ modelName,
440
+ name: modelName,
441
+ };
442
+ const modelArg = Object.assign(new String(modelName), context, {
443
+ name: modelName,
444
+ modelName,
445
+ model: modelName,
446
+ valueOf: () => modelName,
447
+ toString: () => modelName,
448
+ [Symbol.toPrimitive]: () => modelName,
422
449
  });
450
+ const result = await action.handler(records, modelArg, context);
423
451
 
424
452
  res.json({ success: true, result, affected: records.length });
425
453
  } catch (error) {
@@ -671,6 +699,10 @@ function createExtensionApiHandlers(options) {
671
699
  }
672
700
  }
673
701
 
702
+ if (registry && typeof registry.runHooks === 'function') {
703
+ await registry.runHooks('onDashboardLoad', { stats, req, user: req.session?.adminUser, db });
704
+ }
705
+
674
706
  res.json({ stats });
675
707
  } catch (error) {
676
708
  res.status(500).json({ error: error.message });
@@ -4,6 +4,8 @@
4
4
  * @module plugins/admin-panel/core/registry
5
5
  */
6
6
 
7
+ const fs = require('fs');
8
+
7
9
  /**
8
10
  * Extension types
9
11
  */
@@ -46,6 +48,7 @@ class AdminRegistry {
46
48
 
47
49
  // Client components: JS code strings for custom page rendering
48
50
  this.clientComponents = new Map();
51
+ this.clientComponentFiles = new Map();
49
52
 
50
53
  // Custom scripts & styles for Admin Panel SSR rendering
51
54
  this.scripts = [];
@@ -59,6 +62,8 @@ class AdminRegistry {
59
62
  afterUpdate: [],
60
63
  beforeDelete: [],
61
64
  afterDelete: [],
65
+ beforeRestore: [],
66
+ afterRestore: [],
62
67
  onRecordView: [],
63
68
  onDashboardLoad: [],
64
69
  };
@@ -66,6 +71,7 @@ class AdminRegistry {
66
71
  // Settings
67
72
  this.settings = {
68
73
  title: 'Admin Panel',
74
+ titleTemplate: '%s | %b',
69
75
  logo: null,
70
76
  primaryColor: '#3B82F6',
71
77
  perPage: 20,
@@ -335,17 +341,41 @@ class AdminRegistry {
335
341
  * @param {string} pageId - Page ID (must match a registered page)
336
342
  * @param {string} jsCode - JavaScript code string that defines the component.
337
343
  * The code should assign a Mithril component to window.__customPages[pageId].
344
+ * @param {string} [filePath=null] - Absolute file path for dev mode live reloading
338
345
  */
339
- registerClientComponent(pageId, jsCode) {
346
+ registerClientComponent(pageId, jsCode, filePath = null) {
340
347
  this.clientComponents.set(pageId, jsCode);
348
+ if (filePath) {
349
+ this.clientComponentFiles.set(pageId, filePath);
350
+ }
341
351
  return this;
342
352
  }
343
353
 
344
354
  /**
345
- * Get all client component code strings concatenated
355
+ * Get all client component code strings concatenated.
356
+ * In development mode (NODE_ENV !== 'production'), dynamically re-reads modified
357
+ * component files from disk to eliminate manual server restarts.
346
358
  */
347
359
  getClientComponents() {
348
- return Array.from(this.clientComponents.values()).join('\n');
360
+ const isDev = process.env.NODE_ENV !== 'production';
361
+ const parts = [];
362
+ for (const [pageId, code] of this.clientComponents.entries()) {
363
+ const filePath = this.clientComponentFiles.get(pageId);
364
+ if (isDev && filePath) {
365
+ try {
366
+ if (fs.existsSync(filePath)) {
367
+ const raw = fs.readFileSync(filePath, 'utf8');
368
+ const { formatClientComponent } = require('./admin-module');
369
+ parts.push(formatClientComponent(pageId, raw));
370
+ continue;
371
+ }
372
+ } catch (e) {
373
+ console.error(`[Admin Panel] Failed to reload component for "${pageId}":`, e);
374
+ }
375
+ }
376
+ parts.push(code || '');
377
+ }
378
+ return parts.filter(Boolean).join('\n');
349
379
  }
350
380
 
351
381
  /**
@@ -382,6 +412,44 @@ class AdminRegistry {
382
412
  return this;
383
413
  }
384
414
 
415
+ /**
416
+ * Set base admin panel title
417
+ * @param {string} title - Base title (e.g., 'Acme Admin')
418
+ */
419
+ setTitle(title) {
420
+ if (typeof title === 'string') {
421
+ this.settings.title = title;
422
+ }
423
+ return this;
424
+ }
425
+
426
+ /**
427
+ * Get base admin panel title
428
+ * @returns {string}
429
+ */
430
+ getTitle() {
431
+ return this.settings.title;
432
+ }
433
+
434
+ /**
435
+ * Set admin page title template
436
+ * @param {string} template - Title template (e.g., '%s | %b', '%s — %b', '%s')
437
+ */
438
+ setTitleTemplate(template) {
439
+ if (typeof template === 'string') {
440
+ this.settings.titleTemplate = template;
441
+ }
442
+ return this;
443
+ }
444
+
445
+ /**
446
+ * Get admin page title template
447
+ * @returns {string}
448
+ */
449
+ getTitleTemplate() {
450
+ return this.settings.titleTemplate;
451
+ }
452
+
385
453
  /**
386
454
  * Enable user management
387
455
  * @param {Object} config - User management configuration
@@ -471,19 +539,30 @@ class AdminRegistry {
471
539
  toClientConfig() {
472
540
  return {
473
541
  settings: this.settings,
474
- pages: this.getPages().map(p => ({
475
- id: p.id,
476
- title: p.title,
477
- path: p.path,
478
- icon: p.icon,
479
- description: p.description,
480
- permission: p.permission,
481
- layout: p.layout !== undefined ? p.layout : true,
482
- iframe: p.iframe,
483
- url: p.url || p.iframeUrl,
484
- html: p.html,
485
- hasClientComponent: this.clientComponents.has(p.id),
486
- })),
542
+ pages: this.getPages().map(p => {
543
+ let html = p.html;
544
+ if (p.htmlFile && process.env.NODE_ENV !== 'production') {
545
+ try {
546
+ if (fs.existsSync(p.htmlFile)) {
547
+ html = fs.readFileSync(p.htmlFile, 'utf8');
548
+ }
549
+ } catch (e) {}
550
+ }
551
+ return {
552
+ id: p.id,
553
+ title: p.title,
554
+ documentTitle: p.documentTitle,
555
+ path: p.path,
556
+ icon: p.icon,
557
+ description: p.description,
558
+ permission: p.permission,
559
+ layout: p.layout !== undefined ? p.layout : true,
560
+ iframe: p.iframe,
561
+ url: p.url || p.iframeUrl,
562
+ html,
563
+ hasClientComponent: this.clientComponents.has(p.id),
564
+ };
565
+ }),
487
566
 
488
567
  widgets: this.getWidgets().map(w => ({
489
568
  id: w.id,
@@ -527,6 +606,7 @@ class AdminRegistry {
527
606
  this.menuGroups.clear();
528
607
  this.fieldRenderers.clear();
529
608
  this.clientComponents.clear();
609
+ this.clientComponentFiles.clear();
530
610
  this.scripts = [];
531
611
  this.styles = [];
532
612
  Object.keys(this.hooks).forEach(k => this.hooks[k] = []);
@@ -43,6 +43,8 @@ function adminPanelPlugin(options = {}) {
43
43
  const {
44
44
  path: adminPath = '/_admin',
45
45
  enabled = true,
46
+ title,
47
+ titleTemplate,
46
48
  sessionSecret,
47
49
  db,
48
50
  auth,
@@ -52,6 +54,7 @@ function adminPanelPlugin(options = {}) {
52
54
  uploadUrl: uploadUrlOption,
53
55
  richTextSanitize = true,
54
56
  dataExchange: dataExchangeOption = true,
57
+ loginRateLimit,
55
58
  } = options;
56
59
 
57
60
  const adminUsersConfig = typeof adminUsersOption === 'object' ? adminUsersOption : { enabled: adminUsersOption !== false };
@@ -63,6 +66,12 @@ function adminPanelPlugin(options = {}) {
63
66
 
64
67
  // Create fresh registry for this plugin instance
65
68
  const registry = new AdminRegistry();
69
+ if (title) {
70
+ registry.setTitle(title);
71
+ }
72
+ if (titleTemplate) {
73
+ registry.setTitleTemplate(titleTemplate);
74
+ }
66
75
 
67
76
  // Dependencies are now in main package
68
77
  const session = require('express-session');
@@ -144,8 +153,40 @@ function adminPanelPlugin(options = {}) {
144
153
  registerStyle(style) {
145
154
  return registry.registerStyle(style);
146
155
  },
156
+ registerHook(hookName, callback) {
157
+ return registry.registerHook(hookName, callback);
158
+ },
159
+ runHooks(hookName, context) {
160
+ return registry.runHooks(hookName, context);
161
+ },
162
+ registerAction(id, config) {
163
+ return registry.registerAction(id, config);
164
+ },
165
+ registerBulkAction(id, config) {
166
+ return registry.registerBulkAction(id, config);
167
+ },
168
+ setTitle(title) {
169
+ registry.setTitle(title);
170
+ return this;
171
+ },
172
+ getTitle() {
173
+ return registry.getTitle();
174
+ },
175
+ setTitleTemplate(template) {
176
+ registry.setTitleTemplate(template);
177
+ return this;
178
+ },
179
+ getTitleTemplate() {
180
+ return registry.getTitleTemplate();
181
+ },
182
+ resetLoginAttempts(ip) {
183
+ if (this._apiHandlers && typeof this._apiHandlers.resetLoginAttempts === 'function') {
184
+ return this._apiHandlers.resetLoginAttempts(ip);
185
+ }
186
+ },
147
187
 
148
188
  _ctx: null,
189
+ _apiHandlers: null,
149
190
  },
150
191
 
151
192
  /**
@@ -275,15 +316,68 @@ function adminPanelPlugin(options = {}) {
275
316
  });
276
317
  }
277
318
 
319
+ // Auto-register model-level declared actions and bulkActions
320
+ const { getAllModels: getFrameworkModels } = require('../../core/orm/model');
321
+ const modelMap = new Map();
322
+ if (getFrameworkModels) {
323
+ for (const [name, model] of getFrameworkModels()) {
324
+ modelMap.set(name, model);
325
+ }
326
+ }
327
+ if (db.getAllModels) {
328
+ const dbModels = db.getAllModels();
329
+ const list = Array.isArray(dbModels) ? dbModels : Array.from(dbModels.values());
330
+ for (const model of list) {
331
+ modelMap.set(model.name, model);
332
+ }
333
+ }
334
+ const appModelList = Array.from(modelMap.values());
335
+ for (const model of appModelList) {
336
+ if (model.admin?.actions && Array.isArray(model.admin.actions)) {
337
+ for (const act of model.admin.actions) {
338
+ if (act.id && act.label && act.handler && !registry.actions.has(act.id)) {
339
+ registry.registerAction(act.id, {
340
+ models: [model.name],
341
+ ...act,
342
+ });
343
+ }
344
+ }
345
+ }
346
+ if (model.admin?.bulkActions && Array.isArray(model.admin.bulkActions)) {
347
+ for (const bAct of model.admin.bulkActions) {
348
+ if (bAct.id && bAct.label && bAct.handler && !registry.bulkActions.has(bAct.id)) {
349
+ registry.registerBulkAction(bAct.id, {
350
+ models: [model.name],
351
+ ...bAct,
352
+ });
353
+ }
354
+ }
355
+ }
356
+ if (model.admin?.hooks && typeof model.admin.hooks === 'object') {
357
+ for (const [hookName, fn] of Object.entries(model.admin.hooks)) {
358
+ if (typeof fn === 'function') {
359
+ registry.registerHook(hookName, async (ctx) => {
360
+ if (!ctx.model || ctx.model === model.name) {
361
+ await fn(ctx);
362
+ }
363
+ });
364
+ }
365
+ }
366
+ }
367
+ }
368
+
278
369
  // Create API handlers
279
370
  const apiHandlers = createApiHandlers({
280
371
  path: adminPath,
281
372
  db,
373
+ registry,
282
374
  AdminUser,
283
375
  hashPassword: (password, rounds) => bcrypt.hash(password, process.env.NODE_ENV === 'test' ? 4 : (rounds || 10)),
284
376
  comparePassword: (password, hash) => bcrypt.compare(password, hash),
285
377
  richTextSanitize,
378
+ loginRateLimit,
286
379
  });
380
+ this.api._apiHandlers = apiHandlers;
287
381
 
288
382
  const extensionHandlers = createExtensionApiHandlers({
289
383
  registry,
@@ -330,7 +424,9 @@ function adminPanelPlugin(options = {}) {
330
424
  ctx.addRoute('get', `${adminPath}/api/extensions/widgets/:widgetId/data`, requireAuth, extensionHandlers.widgetDataHandler);
331
425
  ctx.addRoute('get', `${adminPath}/api/extensions/dashboard/stats`, requireAuth, extensionHandlers.dashboardStatsHandler);
332
426
  ctx.addRoute('post', `${adminPath}/api/extensions/actions/:actionId/:model/:id`, requireAuth, extensionHandlers.actionHandler);
427
+ ctx.addRoute('post', `${adminPath}/api/actions/:actionId/:model/:id`, requireAuth, extensionHandlers.actionHandler);
333
428
  ctx.addRoute('post', `${adminPath}/api/extensions/bulk-actions/:actionId/:model`, requireAuth, extensionHandlers.bulkActionHandler);
429
+ ctx.addRoute('post', `${adminPath}/api/bulk-actions/:actionId/:model`, requireAuth, extensionHandlers.bulkActionHandler);
334
430
  ctx.addRoute('get', `${adminPath}/api/extensions/bulk-fields/:model`, requireAuth, extensionHandlers.bulkFieldsHandler);
335
431
  ctx.addRoute('post', `${adminPath}/api/extensions/bulk-update/:model`, requireAuth, extensionHandlers.bulkUpdateFieldHandler);
336
432
  ctx.addRoute('get', `${adminPath}/api/extensions/export/:model`, requireAuth, extensionHandlers.exportHandler);
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * In-memory brute-force rate limiter for admin login.
5
+ * Zero external dependencies.
6
+ */
7
+ class LoginRateLimiter {
8
+ constructor(options = {}) {
9
+ if (options === false) {
10
+ this.enabled = false;
11
+ this.maxAttempts = 5;
12
+ this.windowMs = 15 * 60 * 1000;
13
+ } else {
14
+ const opts = typeof options === 'object' && options !== null ? options : {};
15
+ this.enabled = opts.enabled !== false;
16
+ this.maxAttempts = typeof opts.maxAttempts === 'number' && opts.maxAttempts > 0 ? opts.maxAttempts : 5;
17
+ this.windowMs = typeof opts.windowMs === 'number' && opts.windowMs > 0 ? opts.windowMs : (15 * 60 * 1000);
18
+ }
19
+ this.attempts = new Map();
20
+ }
21
+
22
+ getClientIp(req) {
23
+ if (!req) return '127.0.0.1';
24
+ if (req.ip) return String(req.ip);
25
+ if (req.headers && req.headers['x-forwarded-for']) {
26
+ const forwarded = String(req.headers['x-forwarded-for']).split(',')[0].trim();
27
+ if (forwarded) return forwarded;
28
+ }
29
+ if (req.connection && req.connection.remoteAddress) {
30
+ return String(req.connection.remoteAddress);
31
+ }
32
+ if (req.socket && req.socket.remoteAddress) {
33
+ return String(req.socket.remoteAddress);
34
+ }
35
+ return '127.0.0.1';
36
+ }
37
+
38
+ isBlocked(req) {
39
+ if (!this.enabled) return { blocked: false, remaining: Infinity, count: 0 };
40
+ const ip = this.getClientIp(req);
41
+ const now = Date.now();
42
+ const entry = this.attempts.get(ip);
43
+ if (!entry) return { blocked: false, remaining: this.maxAttempts, count: 0 };
44
+
45
+ if (now >= entry.resetAt) {
46
+ this.attempts.delete(ip);
47
+ return { blocked: false, remaining: this.maxAttempts, count: 0 };
48
+ }
49
+
50
+ if (entry.count >= this.maxAttempts) {
51
+ const remainingMs = Math.max(0, entry.resetAt - now);
52
+ const waitMinutes = Math.max(1, Math.ceil(remainingMs / 60000));
53
+ const retryAfter = Math.max(1, Math.ceil(remainingMs / 1000));
54
+ return {
55
+ blocked: true,
56
+ remaining: 0,
57
+ count: entry.count,
58
+ waitMinutes,
59
+ retryAfter,
60
+ message: `Too many failed login attempts. Please try again in ${waitMinutes} ${waitMinutes === 1 ? 'minute' : 'minutes'}.`,
61
+ };
62
+ }
63
+
64
+ return {
65
+ blocked: false,
66
+ remaining: Math.max(0, this.maxAttempts - entry.count),
67
+ count: entry.count,
68
+ };
69
+ }
70
+
71
+ recordFailure(req) {
72
+ if (!this.enabled) return { blocked: false, remaining: Infinity, count: 0 };
73
+ const ip = this.getClientIp(req);
74
+ const now = Date.now();
75
+ const entry = this.attempts.get(ip);
76
+
77
+ if (!entry || now >= entry.resetAt) {
78
+ this.attempts.set(ip, {
79
+ count: 1,
80
+ resetAt: now + this.windowMs,
81
+ });
82
+ } else {
83
+ entry.count += 1;
84
+ }
85
+
86
+ if (this.attempts.size > 500) {
87
+ this.cleanup();
88
+ }
89
+
90
+ return this.isBlocked(req);
91
+ }
92
+
93
+ recordSuccess(req) {
94
+ if (!this.enabled) return;
95
+ const ip = this.getClientIp(req);
96
+ this.attempts.delete(ip);
97
+ }
98
+
99
+ reset(ip) {
100
+ if (ip) {
101
+ this.attempts.delete(ip);
102
+ } else {
103
+ this.attempts.clear();
104
+ }
105
+ }
106
+
107
+ cleanup() {
108
+ const now = Date.now();
109
+ for (const [ip, entry] of this.attempts.entries()) {
110
+ if (now >= entry.resetAt) {
111
+ this.attempts.delete(ip);
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ module.exports = {
118
+ LoginRateLimiter,
119
+ };
@@ -497,7 +497,16 @@ const AdminUsersPage = {
497
497
  };
498
498
 
499
499
  vnode.state.deleteAdmin = async function(admin) {
500
- if (!confirm('Are you sure you want to delete administrator "' + (admin.name || admin.email) + '"? This action cannot be undone.')) {
500
+ var confirmed = typeof confirmAction === 'function'
501
+ ? await confirmAction({
502
+ title: 'Delete Administrator',
503
+ message: 'Are you sure you want to delete administrator "' + (admin.name || admin.email) + '"? This action cannot be undone.',
504
+ danger: true,
505
+ confirmText: 'Delete'
506
+ })
507
+ : confirm('Are you sure you want to delete administrator "' + (admin.name || admin.email) + '"? This action cannot be undone.');
508
+
509
+ if (!confirmed) {
501
510
  return;
502
511
  }
503
512
  vnode.state.loading = true;
@@ -506,9 +515,15 @@ const AdminUsersPage = {
506
515
  try {
507
516
  await api.delete('/admins/' + encodeURIComponent(admin.id));
508
517
  vnode.state.success = 'Administrator deleted successfully';
518
+ if (typeof notifySuccess === 'function') {
519
+ notifySuccess('Administrator deleted successfully');
520
+ }
509
521
  await vnode.state.loadAdmins();
510
522
  } catch (err) {
511
523
  vnode.state.error = err.message || 'Failed to delete administrator';
524
+ if (typeof notifyError === 'function') {
525
+ notifyError(vnode.state.error);
526
+ }
512
527
  vnode.state.loading = false;
513
528
  m.redraw();
514
529
  }