millas 0.2.12-beta-1 → 0.2.13

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 (120) hide show
  1. package/package.json +3 -2
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +516 -199
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +318 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +393 -97
  12. package/src/admin/static/admin.css +1422 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +87 -1046
  19. package/src/admin/views/pages/detail.njk +56 -21
  20. package/src/admin/views/pages/error.njk +65 -0
  21. package/src/admin/views/pages/form.njk +47 -599
  22. package/src/admin/views/pages/list.njk +270 -62
  23. package/src/admin/views/partials/form-field.njk +53 -0
  24. package/src/admin/views/partials/form-footer.njk +28 -0
  25. package/src/admin/views/partials/form-readonly.njk +114 -0
  26. package/src/admin/views/partials/form-scripts.njk +480 -0
  27. package/src/admin/views/partials/form-widget.njk +297 -0
  28. package/src/admin/views/partials/icons.njk +64 -0
  29. package/src/admin/views/partials/json-dialog.njk +80 -0
  30. package/src/admin/views/partials/json-editor.njk +37 -0
  31. package/src/ai/AIManager.js +954 -0
  32. package/src/ai/AITokenBudget.js +250 -0
  33. package/src/ai/PromptGuard.js +216 -0
  34. package/src/ai/agents.js +218 -0
  35. package/src/ai/conversation.js +213 -0
  36. package/src/ai/drivers.js +734 -0
  37. package/src/ai/files.js +249 -0
  38. package/src/ai/media.js +303 -0
  39. package/src/ai/pricing.js +152 -0
  40. package/src/ai/provider_tools.js +114 -0
  41. package/src/ai/types.js +356 -0
  42. package/src/auth/Auth.js +18 -2
  43. package/src/auth/AuthUser.js +65 -44
  44. package/src/cli.js +3 -1
  45. package/src/commands/createsuperuser.js +267 -0
  46. package/src/commands/lang.js +589 -0
  47. package/src/commands/migrate.js +154 -81
  48. package/src/commands/serve.js +3 -4
  49. package/src/container/AppInitializer.js +101 -20
  50. package/src/container/Application.js +31 -1
  51. package/src/container/MillasApp.js +10 -3
  52. package/src/container/MillasConfig.js +35 -6
  53. package/src/core/admin.js +5 -0
  54. package/src/core/db.js +2 -1
  55. package/src/core/foundation.js +2 -10
  56. package/src/core/lang.js +1 -0
  57. package/src/errors/HttpError.js +32 -16
  58. package/src/facades/AI.js +411 -0
  59. package/src/facades/Hash.js +67 -0
  60. package/src/facades/Process.js +144 -0
  61. package/src/hashing/Hash.js +262 -0
  62. package/src/http/HtmlEscape.js +162 -0
  63. package/src/http/MillasRequest.js +63 -7
  64. package/src/http/MillasResponse.js +70 -4
  65. package/src/http/ResponseDispatcher.js +21 -27
  66. package/src/http/SafeFilePath.js +195 -0
  67. package/src/http/SafeRedirect.js +62 -0
  68. package/src/http/SecurityBootstrap.js +70 -0
  69. package/src/http/helpers.js +40 -125
  70. package/src/http/index.js +10 -1
  71. package/src/http/middleware/CsrfMiddleware.js +258 -0
  72. package/src/http/middleware/RateLimiter.js +314 -0
  73. package/src/http/middleware/SecurityHeaders.js +281 -0
  74. package/src/i18n/I18nServiceProvider.js +91 -0
  75. package/src/i18n/Translator.js +643 -0
  76. package/src/i18n/defaults.js +122 -0
  77. package/src/i18n/index.js +164 -0
  78. package/src/i18n/locales/en.js +55 -0
  79. package/src/i18n/locales/sw.js +48 -0
  80. package/src/logger/LogRedactor.js +247 -0
  81. package/src/logger/Logger.js +1 -1
  82. package/src/logger/formatters/JsonFormatter.js +11 -4
  83. package/src/logger/formatters/PrettyFormatter.js +103 -65
  84. package/src/logger/formatters/SimpleFormatter.js +14 -3
  85. package/src/middleware/ThrottleMiddleware.js +27 -4
  86. package/src/migrations/system/0001_users.js +21 -0
  87. package/src/migrations/system/0002_admin_log.js +25 -0
  88. package/src/migrations/system/0003_sessions.js +23 -0
  89. package/src/orm/fields/index.js +210 -188
  90. package/src/orm/migration/DefaultValueParser.js +325 -0
  91. package/src/orm/migration/InteractiveResolver.js +191 -0
  92. package/src/orm/migration/Makemigrations.js +312 -0
  93. package/src/orm/migration/MigrationGraph.js +227 -0
  94. package/src/orm/migration/MigrationRunner.js +202 -108
  95. package/src/orm/migration/MigrationWriter.js +463 -0
  96. package/src/orm/migration/ModelInspector.js +143 -74
  97. package/src/orm/migration/ModelScanner.js +225 -0
  98. package/src/orm/migration/ProjectState.js +213 -0
  99. package/src/orm/migration/RenameDetector.js +175 -0
  100. package/src/orm/migration/SchemaBuilder.js +8 -81
  101. package/src/orm/migration/operations/base.js +57 -0
  102. package/src/orm/migration/operations/column.js +191 -0
  103. package/src/orm/migration/operations/fields.js +252 -0
  104. package/src/orm/migration/operations/index.js +55 -0
  105. package/src/orm/migration/operations/models.js +152 -0
  106. package/src/orm/migration/operations/registry.js +131 -0
  107. package/src/orm/migration/operations/special.js +51 -0
  108. package/src/orm/migration/utils.js +208 -0
  109. package/src/orm/model/Model.js +81 -13
  110. package/src/process/Process.js +333 -0
  111. package/src/providers/AdminServiceProvider.js +66 -9
  112. package/src/providers/AuthServiceProvider.js +40 -5
  113. package/src/providers/CacheStorageServiceProvider.js +2 -2
  114. package/src/providers/DatabaseServiceProvider.js +3 -2
  115. package/src/providers/LogServiceProvider.js +4 -1
  116. package/src/providers/MailServiceProvider.js +1 -1
  117. package/src/providers/QueueServiceProvider.js +1 -1
  118. package/src/router/MiddlewareRegistry.js +27 -2
  119. package/src/scaffold/templates.js +80 -21
  120. package/src/validation/Validator.js +348 -607
@@ -2,9 +2,14 @@
2
2
 
3
3
  const path = require('path');
4
4
  const nunjucks = require('nunjucks');
5
- const ActivityLog = require('./ActivityLog');
5
+ const ActivityLog = require('./ActivityLog');
6
+ const { HookPipeline, AdminHooks } = require('./HookRegistry');
7
+ const { FormGenerator } = require('./FormGenerator');
8
+ const { ViewContext } = require('./ViewContext');
6
9
  const AdminAuth = require('./AdminAuth');
7
10
  const { AdminResource, AdminField, AdminFilter, AdminInline } = require('./resources/AdminResource');
11
+ const LookupParser = require('../orm/query/LookupParser');
12
+ const Facade = require('../facades/Facade');
8
13
 
9
14
  /**
10
15
  * Admin
@@ -79,6 +84,19 @@ class Admin {
79
84
  const prefix = this._config.prefix;
80
85
  this._njk = this._setupNunjucks(expressApp);
81
86
 
87
+ // ── Static assets ────────────────────────────────────────────────────────
88
+ // Serve ui.js from the admin source directory as a static file.
89
+ // Loaded by base.njk as /admin/static/ui.js
90
+ // Serve all files from src/admin/static/ at /admin/static/*
91
+ const _staticPath = require('path').join(__dirname, 'static');
92
+ expressApp.use(prefix + '/static', require('express').static(_staticPath, {
93
+ maxAge: '1h',
94
+ setHeaders(res, filePath) {
95
+ if (filePath.endsWith('.js')) res.setHeader('Content-Type', 'application/javascript');
96
+ if (filePath.endsWith('.css')) res.setHeader('Content-Type', 'text/css');
97
+ },
98
+ }));
99
+
82
100
  // ── Auth middleware (runs before all admin routes) ──────────
83
101
  expressApp.use(prefix, AdminAuth.middleware(prefix));
84
102
 
@@ -107,6 +125,17 @@ class Admin {
107
125
  expressApp.post (`${prefix}/:resource/bulk-action`, (q, s) => this._bulkAction(q, s));
108
126
  expressApp.post (`${prefix}/:resource/:id/action/:action`,(q, s) => this._rowAction(q, s));
109
127
 
128
+ // ── Relationship API ─────────────────────────────────────────────────────
129
+ // Used by FK and M2M widgets to fetch options via autocomplete.
130
+ // Returns JSON: [{ id, label }, ...]
131
+ expressApp.get(`${prefix}/api/:resource/options`, (q, s) => this._apiOptions(q, s));
132
+
133
+ // ── Inline CRUD routes ───────────────────────────────────────────────────
134
+ // Inline create: POST /admin/:resource/:id/inline/:inlineIndex
135
+ // Inline delete: POST /admin/:resource/:id/inline/:inlineIndex/:rowId/delete
136
+ expressApp.post(`${prefix}/:resource/:id/inline/:inlineIndex`, (q, s) => this._inlineStore(q, s));
137
+ expressApp.post(`${prefix}/:resource/:id/inline/:inlineIndex/:rowId/delete`, (q, s) => this._inlineDestroy(q, s));
138
+
110
139
  return this;
111
140
  }
112
141
 
@@ -121,6 +150,17 @@ class Admin {
121
150
  });
122
151
 
123
152
  // ── Custom filters ───────────────────────────────────────────
153
+
154
+ // Resolve a fkResource table name to the registered admin slug (or null)
155
+ const resolveFkSlug = (tableName) => {
156
+ if (!tableName) return null;
157
+ if (this._resources.has(tableName)) return tableName;
158
+ for (const R of this._resources.values()) {
159
+ if (R.model && R.model.table === tableName) return R.slug;
160
+ }
161
+ return null;
162
+ };
163
+
124
164
  env.addFilter('adminCell', (value, field) => {
125
165
  if (value === null || value === undefined) return '<span class="cell-muted">—</span>';
126
166
  switch (field.type) {
@@ -154,6 +194,22 @@ class Admin {
154
194
  return `<code class="cell-mono">${JSON.stringify(value).slice(0, 40)}…</code>`;
155
195
  case 'email':
156
196
  return `<a href="mailto:${value}" style="color:var(--primary);text-decoration:none">${value}</a>`;
197
+ case 'url':
198
+ return `<a href="${value}" target="_blank" rel="noopener" style="color:var(--primary);text-decoration:none;word-break:break-all">${value}</a>`;
199
+ case 'phone':
200
+ return `<a href="tel:${value}" style="color:var(--primary);text-decoration:none">${value}</a>`;
201
+ case 'color':
202
+ return `<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:16px;height:16px;border-radius:3px;background:${value};border:1px solid var(--border);flex-shrink:0"></span><span class="cell-mono">${value}</span></span>`;
203
+ case 'richtext':
204
+ return `<div style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-soft)">${String(value).replace(/<[^>]+>/g, '').slice(0, 80)}</div>`;
205
+ case 'fk': {
206
+ const fkSlug = resolveFkSlug(field.fkResource);
207
+ const prefix = this._config.prefix || '/admin';
208
+ if (fkSlug) {
209
+ return `<span class="fk-cell">${value}<a class="fk-arrow-btn" href="${prefix}/${fkSlug}/${value}" title="View record #${value}"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></span>`;
210
+ }
211
+ return String(value);
212
+ }
157
213
  default: {
158
214
  const str = String(value);
159
215
  return str.length > 60
@@ -201,6 +257,21 @@ class Admin {
201
257
  } catch { return String(value); }
202
258
  case 'richtext':
203
259
  return `<div style="line-height:1.6;color:var(--text-soft)">${value}</div>`;
260
+ case 'phone':
261
+ return `<a href="tel:${value}" style="color:var(--primary)">${value}</a>`;
262
+ case 'badge': {
263
+ const colorMap2 = { admin:'purple', user:'blue', active:'green', inactive:'gray', pending:'yellow', published:'green', draft:'gray', banned:'red' };
264
+ const c2 = (field.colors && field.colors[String(value)]) || colorMap2[String(value)] || 'gray';
265
+ return `<span class="badge badge-${c2}">${value}</span>`;
266
+ }
267
+ case 'fk': {
268
+ const fkSlug = resolveFkSlug(field.fkResource);
269
+ const prefix = this._config.prefix || '/admin';
270
+ if (fkSlug) {
271
+ return `<span class="fk-cell fk-cell-detail">${value}<a class="fk-arrow-btn" href="${prefix}/${fkSlug}/${value}" title="View record #${value}"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></span>`;
272
+ }
273
+ return String(value);
274
+ }
204
275
  default: {
205
276
  const str = String(value);
206
277
  return str;
@@ -213,6 +284,13 @@ class Admin {
213
284
 
214
285
  env.addFilter('min', (arr) => Math.min(...arr));
215
286
 
287
+ // tabId: convert a tab name to a CSS/jQuery safe id fragment.
288
+ // Strips everything that is not alphanumeric, underscore, or hyphen.
289
+ // 'Role & Access' → 'Role--Access', 'Details' → 'Details'
290
+ env.addFilter('tabId', (name) =>
291
+ String(name).replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''));
292
+
293
+
216
294
  env.addFilter('relativeTime', (iso) => {
217
295
  try {
218
296
  const diff = Date.now() - new Date(iso).getTime();
@@ -230,19 +308,43 @@ class Admin {
230
308
  // ─── Base render context ──────────────────────────────────────────────────
231
309
 
232
310
  _ctx(req, extra = {}) {
311
+ // Resolve the auth user model from the container so we can tag its
312
+ // resource as 'auth' in the sidebar — automatic, no dev config needed.
313
+ let authUserModel = null;
314
+ try {
315
+ const container = Facade._container;
316
+ if (container) {
317
+ const auth = container.make('auth');
318
+ authUserModel = auth?._UserModel || null;
319
+ }
320
+ } catch { /* container not booted yet or auth not registered */ }
321
+
322
+ // A resource is in the 'auth' category if:
323
+ // 1. Its model is the configured auth_user model, OR
324
+ // 2. The developer explicitly set static authCategory = 'auth'
325
+ const isAuthResource = (r) => {
326
+ if (r.authCategory === 'auth') return true;
327
+ if (authUserModel && r.model && r.model === authUserModel) return true;
328
+ return false;
329
+ };
330
+
233
331
  return {
332
+ csrfToken: AdminAuth.enabled ? AdminAuth.csrfToken(req) : 'disabled',
234
333
  adminPrefix: this._config.prefix,
235
334
  adminTitle: this._config.title,
236
335
  adminUser: req.adminUser || null,
237
336
  authEnabled: AdminAuth.enabled,
238
- resources: this.resources().map((r, idx) => ({
239
- slug: r.slug,
240
- label: r._getLabel(),
241
- singular: r._getLabelSingular(),
242
- icon: r.icon,
243
- canView: r.canView,
244
- index: idx + 1,
245
- })),
337
+ resources: this.resources()
338
+ .filter(r => r.hasPermission(req.adminUser || null, 'view'))
339
+ .map((r, idx) => ({
340
+ slug: r.slug,
341
+ label: r._getLabel(),
342
+ singular: r._getLabelSingular(),
343
+ icon: r.icon,
344
+ canView: r.hasPermission(req.adminUser || null, 'view'),
345
+ index: idx + 1,
346
+ category: isAuthResource(r) ? 'auth' : 'app',
347
+ })),
246
348
  flash: extra._flash || {},
247
349
  activePage: extra.activePage || null,
248
350
  activeResource: extra.activeResource || null,
@@ -258,15 +360,12 @@ class Admin {
258
360
 
259
361
  async _loginPage(req, res) {
260
362
  // Already logged in → redirect to dashboard
261
- if (AdminAuth.enabled) {
262
- const cookies = req.headers.cookie || '';
263
- if (cookies.includes(this._config.auth?.cookieName || 'millas_admin')) {
264
- // Let AdminAuth verify properly
265
- }
363
+ if (AdminAuth.enabled && AdminAuth._getSession(req)) {
364
+ return res.redirect((req.query.next && decodeURIComponent(req.query.next)) || this._config.prefix + '/');
266
365
  }
267
366
 
268
367
  const flash = AdminAuth.getFlash(req, res);
269
- res.render('pages/login.njk', {
368
+ return this._render(req, res, 'pages/login.njk', {
270
369
  adminTitle: this._config.title,
271
370
  adminPrefix: this._config.prefix,
272
371
  flash,
@@ -292,7 +391,7 @@ class Admin {
292
391
 
293
392
  res.redirect(next || prefix + '/');
294
393
  } catch (err) {
295
- res.render('pages/login.njk', {
394
+ return this._render(req, res, 'pages/login.njk', {
296
395
  adminTitle: this._config.title,
297
396
  adminPrefix: prefix,
298
397
  flash: {},
@@ -344,10 +443,12 @@ class Admin {
344
443
  })
345
444
  );
346
445
 
347
- const activityData = ActivityLog.recent(25);
348
- const activityTotals = ActivityLog.totals();
446
+ const [activityData, activityTotals] = await Promise.all([
447
+ ActivityLog.recent(25),
448
+ ActivityLog.totals(),
449
+ ]);
349
450
 
350
- res.render('pages/dashboard.njk', this._ctxWithFlash(req, res, {
451
+ return this._render(req, res, 'pages/dashboard.njk', this._ctxWithFlash(req, res, {
351
452
  pageTitle: 'Dashboard',
352
453
  activePage: 'dashboard',
353
454
  resources: resourceData,
@@ -355,7 +456,7 @@ class Admin {
355
456
  activityTotals,
356
457
  }));
357
458
  } catch (err) {
358
- this._error(res, err);
459
+ this._error(req, res, err);
359
460
  }
360
461
  }
361
462
 
@@ -364,15 +465,17 @@ class Admin {
364
465
  const R = this._resolve(req.params.resource, res);
365
466
  if (!R) return;
366
467
 
367
- const page = Number(req.query.page) || 1;
368
- const search = req.query.search || '';
369
- const sort = req.query.sort || 'id';
370
- const order = req.query.order || 'desc';
371
- const perPage = Number(req.query.perPage) || R.perPage;
372
- const year = req.query.year || null;
373
- const month = req.query.month || null;
468
+ // Parse query params
469
+ const query = {
470
+ page: Number(req.query.page) || 1,
471
+ search: req.query.search || '',
472
+ sort: req.query.sort || 'id',
473
+ order: req.query.order || 'desc',
474
+ perPage:Number(req.query.perPage) || R.perPage,
475
+ year: req.query.year || null,
476
+ month: req.query.month || null,
477
+ };
374
478
 
375
- // Collect active filters
376
479
  const activeFilters = {};
377
480
  if (req.query.filter) {
378
481
  for (const [k, v] of Object.entries(req.query.filter)) {
@@ -380,48 +483,23 @@ class Admin {
380
483
  }
381
484
  }
382
485
 
383
- const result = await R.fetchList({ page, search, sort, order, perPage, filters: activeFilters, year, month });
486
+ const result = await R.fetchList({ ...query, filters: activeFilters });
384
487
  const rows = result.data.map(r => r.toJSON ? r.toJSON() : r);
385
488
 
386
- const listFields = R.fields()
387
- .filter(f => !f._hidden && !f._detailOnly)
388
- .map(f => f.toJSON());
389
-
390
- res.render('pages/list.njk', this._ctxWithFlash(req, res, {
391
- pageTitle: R._getLabel(),
392
- activeResource: req.params.resource,
393
- resource: {
394
- slug: R.slug,
395
- label: R._getLabel(),
396
- singular: R._getLabelSingular(),
397
- icon: R.icon,
398
- canCreate: R.canCreate,
399
- canEdit: R.canEdit,
400
- canDelete: R.canDelete,
401
- canView: R.canView,
402
- actions: (R.actions || []).map((a, i) => ({ ...a, index: i, handler: undefined })),
403
- rowActions: R.rowActions || [],
404
- listDisplayLinks: R.listDisplayLinks || [],
405
- dateHierarchy: R.dateHierarchy || null,
406
- prepopulatedFields: R.prepopulatedFields || {},
407
- },
408
- rows,
409
- listFields,
410
- filters: R.filters().map(f => f.toJSON()),
411
- activeFilters,
412
- sortable: R.sortable || [],
413
- total: result.total,
414
- page: result.page,
415
- perPage: result.perPage,
416
- lastPage: result.lastPage,
417
- search,
418
- sort,
419
- order,
420
- year,
421
- month,
422
- }));
489
+ const perms = {
490
+ canCreate: this._perm(R, 'add', req.adminUser),
491
+ canEdit: this._perm(R, 'change', req.adminUser),
492
+ canDelete: this._perm(R, 'delete', req.adminUser),
493
+ canView: this._perm(R, 'view', req.adminUser),
494
+ };
495
+
496
+ return this._render(req, res, 'pages/list.njk',
497
+ ViewContext.list(R, {
498
+ rows, result, query, activeFilters, perms,
499
+ baseCtx: this._ctxWithFlash(req, res, {}),
500
+ }), R);
423
501
  } catch (err) {
424
- this._error(res, err);
502
+ this._error(req, res, err);
425
503
  }
426
504
  }
427
505
 
@@ -429,20 +507,15 @@ class Admin {
429
507
  try {
430
508
  const R = this._resolve(req.params.resource, res);
431
509
  if (!R) return;
432
- if (!R.canCreate) return res.status(403).send('Not allowed');
433
-
434
- res.render('pages/form.njk', this._ctxWithFlash(req, res, {
435
- pageTitle: `New ${R._getLabelSingular()}`,
436
- activeResource: req.params.resource,
437
- resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: false },
438
- formFields: this._formFields(R),
439
- formAction: `${this._config.prefix}/${R.slug}`,
440
- isEdit: false,
441
- record: {},
442
- errors: {},
443
- }));
510
+ if (!this._perm(R, 'add', req.adminUser)) return res.status(403).send('You do not have permission to add ${R._getLabelSingular()} records.');
511
+
512
+ return this._render(req, res, 'pages/form.njk',
513
+ ViewContext.create(R, {
514
+ adminPrefix: this._config.prefix,
515
+ baseCtx: this._ctxWithFlash(req, res, {}),
516
+ }), R);
444
517
  } catch (err) {
445
- this._error(res, err);
518
+ this._error(req, res, err);
446
519
  }
447
520
  }
448
521
 
@@ -450,10 +523,11 @@ class Admin {
450
523
  try {
451
524
  const R = this._resolve(req.params.resource, res);
452
525
  if (!R) return;
453
- if (!R.canCreate) return res.status(403).send('Not allowed');
526
+ if (!this._perm(R, 'add', req.adminUser)) return res.status(403).send('You do not have permission to add ${R._getLabelSingular()} records.');
527
+ if (!this._verifyCsrf(req, res)) return;
454
528
 
455
- const record = await R.create(req.body);
456
- ActivityLog.record('create', R.slug, record?.id, `New ${R._getLabelSingular()}`);
529
+ const record = await R.create(req.body, { user: req.adminUser, resource: R });
530
+ ActivityLog.record('create', R.slug, record?.id, `New ${R._getLabelSingular()}`, req.adminUser);
457
531
 
458
532
  const submit = req.body._submit || 'save';
459
533
  if (submit === 'continue' && record?.id) {
@@ -470,18 +544,15 @@ class Admin {
470
544
  } catch (err) {
471
545
  if (err.status === 422) {
472
546
  const R = this._resources.get(req.params.resource);
473
- return res.render('pages/form.njk', this._ctxWithFlash(req, res, {
474
- pageTitle: `New ${R._getLabelSingular()}`,
475
- activeResource: req.params.resource,
476
- resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: false },
477
- formFields: this._formFields(R),
478
- formAction: `${this._config.prefix}/${R.slug}`,
479
- isEdit: false,
480
- record: req.body,
481
- errors: err.errors || {},
482
- }));
547
+ return this._render(req, res, 'pages/form.njk',
548
+ ViewContext.create(R, {
549
+ adminPrefix: this._config.prefix,
550
+ record: req.body,
551
+ errors: err.errors || {},
552
+ baseCtx: this._ctxWithFlash(req, res, {}),
553
+ }), R);
483
554
  }
484
- this._error(res, err);
555
+ this._error(req, res, err);
485
556
  }
486
557
  }
487
558
 
@@ -489,23 +560,21 @@ class Admin {
489
560
  try {
490
561
  const R = this._resolve(req.params.resource, res);
491
562
  if (!R) return;
492
- if (!R.canEdit) return res.status(403).send('Not allowed');
563
+ if (!this._perm(R, 'change', req.adminUser)) return res.status(403).send('You do not have permission to change ${R._getLabelSingular()} records.');
493
564
 
494
565
  const record = await R.fetchOne(req.params.id);
495
566
  const data = record.toJSON ? record.toJSON() : record;
496
567
 
497
- res.render('pages/form.njk', this._ctxWithFlash(req, res, {
498
- pageTitle: `Edit ${R._getLabelSingular()} #${req.params.id}`,
499
- activeResource: req.params.resource,
500
- resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: R.canDelete },
501
- formFields: this._formFields(R),
502
- formAction: `${this._config.prefix}/${R.slug}/${req.params.id}`,
503
- isEdit: true,
504
- record: data,
505
- errors: {},
506
- }));
568
+ return this._render(req, res, 'pages/form.njk',
569
+ ViewContext.edit(R, {
570
+ adminPrefix: this._config.prefix,
571
+ id: req.params.id,
572
+ record: data,
573
+ canDelete: this._perm(R, 'delete', req.adminUser),
574
+ baseCtx: this._ctxWithFlash(req, res, {}),
575
+ }), R);
507
576
  } catch (err) {
508
- this._error(res, err);
577
+ this._error(req, res, err);
509
578
  }
510
579
  }
511
580
 
@@ -513,13 +582,14 @@ class Admin {
513
582
  try {
514
583
  const R = this._resolve(req.params.resource, res);
515
584
  if (!R) return;
516
- if (!R.canEdit) return res.status(403).send('Not allowed');
585
+ if (!this._perm(R, 'change', req.adminUser)) return res.status(403).send('You do not have permission to change ${R._getLabelSingular()} records.');
586
+ if (!this._verifyCsrf(req, res)) return;
517
587
 
518
588
  // Support method override
519
589
  const method = req.body._method || 'POST';
520
590
  if (method === 'PUT' || method === 'POST') {
521
- await R.update(req.params.id, req.body);
522
- ActivityLog.record('update', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`);
591
+ await R.update(req.params.id, req.body, { user: req.adminUser, resource: R });
592
+ ActivityLog.record('update', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`, req.adminUser);
523
593
 
524
594
  const submit = req.body._submit || 'save';
525
595
  if (submit === 'continue') {
@@ -533,18 +603,17 @@ class Admin {
533
603
  } catch (err) {
534
604
  if (err.status === 422) {
535
605
  const R = this._resources.get(req.params.resource);
536
- return res.render('pages/form.njk', this._ctxWithFlash(req, res, {
537
- pageTitle: `Edit ${R._getLabelSingular()} #${req.params.id}`,
538
- activeResource: req.params.resource,
539
- resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: R.canDelete },
540
- formFields: this._formFields(R),
541
- formAction: `${this._config.prefix}/${R.slug}/${req.params.id}`,
542
- isEdit: true,
543
- record: { id: req.params.id, ...req.body },
544
- errors: err.errors || {},
545
- }));
606
+ return this._render(req, res, 'pages/form.njk',
607
+ ViewContext.edit(R, {
608
+ adminPrefix: this._config.prefix,
609
+ id: req.params.id,
610
+ record: { id: req.params.id, ...req.body },
611
+ canDelete: this._perm(R, 'delete', req.adminUser),
612
+ errors: err.errors || {},
613
+ baseCtx: this._ctxWithFlash(req, res, {}),
614
+ }), R);
546
615
  }
547
- this._error(res, err);
616
+ this._error(req, res, err);
548
617
  }
549
618
  }
550
619
 
@@ -552,14 +621,15 @@ class Admin {
552
621
  try {
553
622
  const R = this._resolve(req.params.resource, res);
554
623
  if (!R) return;
555
- if (!R.canDelete) return res.status(403).send('Not allowed');
624
+ if (!this._perm(R, 'delete', req.adminUser)) return res.status(403).send('You do not have permission to delete ${R._getLabelSingular()} records.');
625
+ if (!this._verifyCsrf(req, res)) return;
556
626
 
557
- await R.destroy(req.params.id);
558
- ActivityLog.record('delete', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`);
627
+ await R.destroy(req.params.id, { user: req.adminUser, resource: R });
628
+ ActivityLog.record('delete', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`, req.adminUser);
559
629
  this._flash(req, 'success', `${R._getLabelSingular()} deleted`);
560
630
  this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
561
631
  } catch (err) {
562
- this._error(res, err);
632
+ this._error(req, res, err);
563
633
  }
564
634
  }
565
635
 
@@ -569,49 +639,36 @@ class Admin {
569
639
  try {
570
640
  const R = this._resolve(req.params.resource, res);
571
641
  if (!R) return;
572
- if (!R.canView) {
573
- if (R.canEdit) return res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}/edit`);
574
- return res.status(403).send('Not allowed');
642
+ if (!this._perm(R, 'view', req.adminUser)) {
643
+ if (this._perm(R, 'change', req.adminUser)) return res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}/edit`);
644
+ return res.status(403).send('You do not have permission to view ${R._getLabelSingular()} records.');
575
645
  }
576
646
 
577
647
  const record = await R.fetchOne(req.params.id);
578
648
  const data = record.toJSON ? record.toJSON() : record;
579
649
 
580
- const detailFields = R.fields()
581
- .filter(f => f._type !== '__tab__' && f._type !== 'fieldset' && !f._hidden && !f._listOnly)
582
- .map(f => f.toJSON());
583
-
584
- const tabs = this._buildTabs(R.fields());
585
-
586
650
  // Load inline related records
587
651
  const inlineData = await Promise.all(
588
- (R.inlines || []).map(async (inline) => {
652
+ (R.inlines || []).map(async (inline, idx) => {
589
653
  const rows = await inline.fetchRows(data[R.model.primaryKey || 'id']);
590
- return { ...inline.toJSON(), rows };
654
+ return { ...inline.toJSON(), rows, inlineIndex: idx };
591
655
  })
592
656
  );
593
657
 
594
- res.render('pages/detail.njk', this._ctxWithFlash(req, res, {
595
- pageTitle: `${R._getLabelSingular()} #${req.params.id}`,
596
- activeResource: req.params.resource,
597
- resource: {
598
- slug: R.slug,
599
- label: R._getLabel(),
600
- singular: R._getLabelSingular(),
601
- icon: R.icon,
602
- canEdit: R.canEdit,
603
- canDelete: R.canDelete,
604
- canCreate: R.canCreate,
605
- rowActions: R.rowActions || [],
606
- },
607
- record: data,
608
- detailFields,
609
- tabs,
610
- hasTabs: tabs.length > 1,
611
- inlines: inlineData,
612
- }));
658
+ return this._render(req, res, 'pages/detail.njk',
659
+ ViewContext.detail(R, {
660
+ id: req.params.id,
661
+ record: data,
662
+ inlineData,
663
+ perms: {
664
+ canEdit: this._perm(R, 'change', req.adminUser),
665
+ canDelete: this._perm(R, 'delete', req.adminUser),
666
+ canCreate: this._perm(R, 'add', req.adminUser),
667
+ },
668
+ baseCtx: this._ctxWithFlash(req, res, {}),
669
+ }), R);
613
670
  } catch (err) {
614
- this._error(res, err);
671
+ this._error(req, res, err);
615
672
  }
616
673
  }
617
674
 
@@ -621,7 +678,8 @@ class Admin {
621
678
  try {
622
679
  const R = this._resolve(req.params.resource, res);
623
680
  if (!R) return;
624
- if (!R.canDelete) return res.status(403).send('Not allowed');
681
+ if (!this._perm(R, 'delete', req.adminUser)) return res.status(403).send('You do not have permission to delete ${R._getLabelSingular()} records.');
682
+ if (!this._verifyCsrf(req, res)) return;
625
683
 
626
684
  const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids].filter(Boolean);
627
685
  if (!ids.length) {
@@ -630,11 +688,11 @@ class Admin {
630
688
  }
631
689
 
632
690
  await R.model.destroy(...ids);
633
- ActivityLog.record('delete', R.slug, null, `${ids.length} ${R._getLabel()} (bulk)`);
691
+ ActivityLog.record('delete', R.slug, null, `${ids.length} ${R._getLabel()} (bulk)`, req.adminUser);
634
692
  this._flash(req, 'success', `Deleted ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
635
693
  this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
636
694
  } catch (err) {
637
- this._error(res, err);
695
+ this._error(req, res, err);
638
696
  }
639
697
  }
640
698
 
@@ -645,6 +703,7 @@ class Admin {
645
703
  const R = this._resolve(req.params.resource, res);
646
704
  if (!R) return;
647
705
 
706
+ if (!this._verifyCsrf(req, res)) return;
648
707
  const actionIndex = Number(req.body.actionIndex);
649
708
  const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids].filter(Boolean);
650
709
  const action = (R.actions || [])[actionIndex];
@@ -660,11 +719,11 @@ class Admin {
660
719
  }
661
720
 
662
721
  await action.handler(ids, R.model);
663
- ActivityLog.record('update', R.slug, null, `Bulk action "${action.label}" on ${ids.length} records`);
722
+ ActivityLog.record('update', R.slug, null, `Bulk action "${action.label}" on ${ids.length} records`, req.adminUser);
664
723
  this._flash(req, 'success', `Action "${action.label}" applied to ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
665
724
  this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
666
725
  } catch (err) {
667
- this._error(res, err);
726
+ this._error(req, res, err);
668
727
  }
669
728
  }
670
729
 
@@ -675,6 +734,7 @@ class Admin {
675
734
  const R = this._resolve(req.params.resource, res);
676
735
  if (!R) return;
677
736
 
737
+ if (!this._verifyCsrf(req, res)) return;
678
738
  const actionName = req.params.action;
679
739
  const rowAction = (R.rowActions || []).find(a => a.action === actionName);
680
740
 
@@ -690,11 +750,199 @@ class Admin {
690
750
  ? result
691
751
  : `${this._config.prefix}/${R.slug}`;
692
752
 
693
- ActivityLog.record('update', R.slug, req.params.id, `Action "${rowAction.label}" on #${req.params.id}`);
753
+ ActivityLog.record('update', R.slug, req.params.id, `Action "${rowAction.label}" on #${req.params.id}`, req.adminUser);
694
754
  this._flash(req, 'success', rowAction.successMessage || `Action "${rowAction.label}" completed.`);
695
755
  this._redirectWithFlash(res, redirect, req._flashType, req._flashMessage);
696
756
  } catch (err) {
697
- this._error(res, err);
757
+ this._error(req, res, err);
758
+ }
759
+ }
760
+
761
+ // ─── Relationship API ────────────────────────────────────────────────────────
762
+
763
+ /**
764
+ * GET /admin/api/:resource/options?q=search&limit=20
765
+ *
766
+ * Returns a JSON array of { id, label } objects for use by FK and M2M
767
+ * widgets in autocomplete selects. The label is derived from the first
768
+ * searchable column on the resource, or falls back to the primary key.
769
+ *
770
+ * Called by the frontend widget via fetch() — no page reload needed.
771
+ */
772
+ async _apiOptions(req, res) {
773
+ try {
774
+ // Look up resource by slug first, then fall back to table name.
775
+ // fkResource on a field is the table name (e.g. 'users') which usually
776
+ // matches the resource slug — but if the developer registered with a
777
+ // custom label the slug may differ. Table-name fallback catches that.
778
+ const slug = req.params.resource;
779
+ let R = this._resources.get(slug);
780
+ if (!R) {
781
+ // Fall back: find a resource whose model.table matches the slug
782
+ for (const resource of this._resources.values()) {
783
+ if (resource.model && resource.model.table === slug) { R = resource; break; }
784
+ }
785
+ }
786
+ if (!R) return res.status(404).json({ error: `Resource "${slug}" not found` });
787
+ if (!R.hasPermission(req.adminUser || null, 'view')) {
788
+ return res.status(403).json({ error: 'Forbidden' });
789
+ }
790
+
791
+ const search = (req.query.q || '').trim();
792
+ const page = Math.max(1, Number(req.query.page) || 1);
793
+ const perPage = Math.min(Number(req.query.limit) || 20, 100);
794
+ const offset = (page - 1) * perPage;
795
+
796
+ const pk = R.model.primaryKey || 'id';
797
+
798
+ // Label column resolution — priority order:
799
+ // 1. resource.fkLabel explicitly set (developer override)
800
+ // 2. resource.searchable[0] — first searchable column
801
+ // 3. Auto-detect from model fields: name > email > title > label > first string field
802
+ // 4. pk as last resort (gives id as label which is unhelpful but safe)
803
+ let labelCol = R.fkLabel || (R.searchable && R.searchable[0]) || null;
804
+ if (!labelCol && R.model) {
805
+ const fields = typeof R.model.getFields === 'function'
806
+ ? R.model.getFields()
807
+ : (R.model.fields || {});
808
+ const preferred = ['name', 'email', 'title', 'label', 'full_name',
809
+ 'fullname', 'username', 'display_name', 'first_name'];
810
+ for (const p of preferred) {
811
+ if (fields[p]) { labelCol = p; break; }
812
+ }
813
+ if (!labelCol) {
814
+ // First string field that isn't a password/token
815
+ const skip = new Set(['password', 'token', 'secret', 'hash', 'remember_token']);
816
+ for (const [col, def] of Object.entries(fields)) {
817
+ if (def.type === 'string' && !skip.has(col) && col !== pk) {
818
+ labelCol = col; break;
819
+ }
820
+ }
821
+ }
822
+ }
823
+ labelCol = labelCol || pk;
824
+
825
+ // Resolve fkWhere — look up the field on the SOURCE resource (the one
826
+ // that owns the FK field), not the target resource being queried.
827
+ // e.g. TenantOwnershipResource.tenant_id has .where({ role: 'tenant' })
828
+ // but we're currently querying UserResource — wrong place to look.
829
+ const fieldName = (req.query.field || '').trim();
830
+ const fromSlug = (req.query.from || '').trim();
831
+ let fkWhere = null;
832
+ if (fieldName && fromSlug) {
833
+ const sourceResource = this._resources.get(fromSlug)
834
+ || [...this._resources.values()].find(r => r.model?.table === fromSlug);
835
+ if (sourceResource) {
836
+ const fieldDef = (sourceResource.fields() || []).find(f => f._name === fieldName);
837
+ if (fieldDef && fieldDef._fkWhere) fkWhere = fieldDef._fkWhere;
838
+ }
839
+ }
840
+
841
+ // Helper: apply fkWhere constraints to a knex query builder.
842
+ // Plain object keys are run through LookupParser so __ syntax works:
843
+ // { role: 'tenant' } → WHERE role = 'tenant'
844
+ // { age__gte: 18 } → WHERE age >= 18
845
+ // { role__in: ['a','b'] } → WHERE role IN ('a','b')
846
+ // { name__icontains: 'alice' } → WHERE name ILIKE '%alice%'
847
+ const applyScope = (q) => {
848
+ if (!fkWhere) return q;
849
+ if (typeof fkWhere === 'function') return fkWhere(q) || q;
850
+ // Plain object — run each key through LookupParser for __ support
851
+ for (const [key, value] of Object.entries(fkWhere)) {
852
+ LookupParser.apply(q, key, value, R.model);
853
+ }
854
+ return q;
855
+ };
856
+
857
+ // Call _db() separately for each query — knex builders are mutable,
858
+ // reusing the same instance across count + select corrupts both queries.
859
+ let countQ = applyScope(R.model._db().count(`${pk} as total`));
860
+ if (search) countQ = countQ.where(labelCol, 'like', `%${search}%`);
861
+ const [{ total }] = await countQ;
862
+
863
+ let rowQ = applyScope(R.model._db()
864
+ .select([`${pk} as id`, `${labelCol} as label`])
865
+ .orderBy(labelCol, 'asc')
866
+ .limit(perPage)
867
+ .offset(offset));
868
+ if (search) rowQ = rowQ.where(labelCol, 'like', `%${search}%`);
869
+ const rows = await rowQ;
870
+
871
+ return res.json({
872
+ data: rows,
873
+ total: Number(total),
874
+ page,
875
+ perPage,
876
+ hasMore: offset + rows.length < Number(total),
877
+ labelCol, // lets the frontend show "Search by <field>…" in the placeholder
878
+ });
879
+ } catch (err) {
880
+ return res.status(500).json({ error: err.message });
881
+ }
882
+ }
883
+
884
+ // ─── Inline CRUD ──────────────────────────────────────────────────────────────
885
+
886
+ /**
887
+ * POST /admin/:resource/:id/inline/:inlineIndex
888
+ *
889
+ * Create a new inline related record.
890
+ * The inlineIndex identifies which AdminInline in R.inlines[] to use.
891
+ */
892
+ async _inlineStore(req, res) {
893
+ try {
894
+ const R = this._resolve(req.params.resource, res);
895
+ if (!R) return;
896
+ if (!this._verifyCsrf(req, res)) return;
897
+
898
+ const idx = Number(req.params.inlineIndex);
899
+ const inline = (R.inlines || [])[idx];
900
+ if (!inline) return res.status(404).send('Inline not found.');
901
+ if (!inline.canCreate) return res.status(403).send('Inline create is disabled.');
902
+
903
+ // Inject the FK value from the parent record ID
904
+ const data = {
905
+ ...req.body,
906
+ [inline.foreignKey]: req.params.id,
907
+ };
908
+ // Strip system fields
909
+ delete data._csrf;
910
+ delete data._method;
911
+ delete data._submit;
912
+
913
+ await inline.model.create(data);
914
+ ActivityLog.record('create', inline.label, null, `Inline ${inline.label} for #${req.params.id}`, req.adminUser);
915
+
916
+ AdminAuth.setFlash(res, 'success', `${inline.label} added.`);
917
+ res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}`);
918
+ } catch (err) {
919
+ this._error(req, res, err);
920
+ }
921
+ }
922
+
923
+ /**
924
+ * POST /admin/:resource/:id/inline/:inlineIndex/:rowId/delete
925
+ *
926
+ * Delete an inline related record.
927
+ */
928
+ async _inlineDestroy(req, res) {
929
+ try {
930
+ const R = this._resolve(req.params.resource, res);
931
+ if (!R) return;
932
+ if (!this._verifyCsrf(req, res)) return;
933
+
934
+ const idx = Number(req.params.inlineIndex);
935
+ const inline = (R.inlines || [])[idx];
936
+ if (!inline) return res.status(404).send('Inline not found.');
937
+ if (!inline.canDelete) return res.status(403).send('Inline delete is disabled.');
938
+
939
+ await inline.model.destroy(req.params.rowId);
940
+ ActivityLog.record('delete', inline.label, req.params.rowId, `Inline ${inline.label} #${req.params.rowId}`, req.adminUser);
941
+
942
+ AdminAuth.setFlash(res, 'success', 'Record deleted.');
943
+ res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}`);
944
+ } catch (err) {
945
+ this._error(req, res, err);
698
946
  }
699
947
  }
700
948
 
@@ -703,13 +951,11 @@ class Admin {
703
951
  const q = (req.query.q || '').trim();
704
952
 
705
953
  if (!q) {
706
- return res.render('pages/search.njk', this._ctxWithFlash(req, res, {
707
- pageTitle: 'Search',
708
- activePage: 'search',
709
- query: '',
710
- results: [],
711
- total: 0,
712
- }));
954
+ return this._render(req, res, 'pages/search.njk',
955
+ ViewContext.search({
956
+ query: '', results: [], total: 0,
957
+ baseCtx: this._ctxWithFlash(req, res, { activePage: 'search' }),
958
+ }));
713
959
  }
714
960
 
715
961
  const results = await Promise.all(
@@ -737,15 +983,13 @@ class Admin {
737
983
  const filtered = results.filter(Boolean);
738
984
  const total = filtered.reduce((s, r) => s + r.total, 0);
739
985
 
740
- res.render('pages/search.njk', this._ctxWithFlash(req, res, {
741
- pageTitle: `Search: ${q}`,
742
- activePage: 'search',
743
- query: q,
744
- results: filtered,
745
- total,
746
- }));
986
+ return this._render(req, res, 'pages/search.njk',
987
+ ViewContext.search({
988
+ query: q, results: filtered, total,
989
+ baseCtx: this._ctxWithFlash(req, res, { activePage: 'search' }),
990
+ }));
747
991
  } catch (err) {
748
- this._error(res, err);
992
+ this._error(req, res, err);
749
993
  }
750
994
  }
751
995
 
@@ -801,7 +1045,7 @@ class Admin {
801
1045
  res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
802
1046
  res.send([header, ...csvRows].join('\r\n'));
803
1047
  } catch (err) {
804
- this._error(res, err);
1048
+ this._error(req, res, err);
805
1049
  }
806
1050
  }
807
1051
 
@@ -846,7 +1090,6 @@ class Admin {
846
1090
 
847
1091
  result.push(json);
848
1092
  }
849
-
850
1093
  return result;
851
1094
  }
852
1095
 
@@ -879,6 +1122,78 @@ class Admin {
879
1122
  return tabs;
880
1123
  }
881
1124
 
1125
+ /**
1126
+ * Resolve a permission for a resource + user combination.
1127
+ * Single chokepoint — every action gate calls this.
1128
+ *
1129
+ * @param {class} R — AdminResource subclass
1130
+ * @param {string} action — 'view'|'add'|'change'|'delete'
1131
+ * @param {object} user — req.adminUser (may be null if auth disabled)
1132
+ */
1133
+ _perm(R, action, user) {
1134
+ return R.hasPermission(user || null, action);
1135
+ }
1136
+
1137
+ /**
1138
+ * Verify the CSRF token on a mutating request.
1139
+ * Checks both req.body._csrf and the X-CSRF-Token header.
1140
+ * Returns true if auth is disabled (non-browser clients).
1141
+ */
1142
+ /**
1143
+ * Render a template with before_render / after_render hooks.
1144
+ *
1145
+ * Replaces direct res.render() calls in every page handler so hooks
1146
+ * can inject extra template data or react after a page is sent.
1147
+ *
1148
+ * @param {object} req
1149
+ * @param {object} res
1150
+ * @param {string} template — e.g. 'pages/list.njk'
1151
+ * @param {object} ctx — template data
1152
+ * @param {class} Resource — AdminConfig subclass (may be null for auth pages)
1153
+ */
1154
+ async _render(req, res, template, ctx, Resource = null) {
1155
+ const start = Date.now();
1156
+
1157
+ // ── before_render ──────────────────────────────────────────────────
1158
+ let finalCtx = ctx;
1159
+ try {
1160
+ const hookCtx = await HookPipeline.run(
1161
+ 'before_render',
1162
+ { view: template, templateCtx: ctx, user: req.adminUser || null, resource: Resource },
1163
+ Resource,
1164
+ AdminHooks,
1165
+ );
1166
+ finalCtx = hookCtx.templateCtx || ctx;
1167
+ } catch (err) {
1168
+ // before_render errors abort the render — surface as a 500
1169
+ return this._error(req, res, err);
1170
+ }
1171
+
1172
+ // ── Render ──────────────────────────────────────────────────────────
1173
+ res.render(template, finalCtx);
1174
+
1175
+ // ── after_render (fire-and-forget) ───────────────────────────────────
1176
+ setImmediate(() => {
1177
+ HookPipeline.run(
1178
+ 'after_render',
1179
+ { view: template, user: req.adminUser || null, resource: Resource, ms: Date.now() - start },
1180
+ Resource,
1181
+ AdminHooks,
1182
+ ).catch(err => {
1183
+ process.stderr.write(`[AdminHooks] after_render error: ${err.message}
1184
+ `);
1185
+ });
1186
+ });
1187
+ }
1188
+
1189
+ _verifyCsrf(req, res) {
1190
+ if (!AdminAuth.enabled) return true;
1191
+ const token = req.body?._csrf || req.headers['x-csrf-token'];
1192
+ if (AdminAuth.verifyCsrf(req, token)) return true;
1193
+ res.status(403).send('CSRF token missing or invalid. Please reload the page and try again.');
1194
+ return false;
1195
+ }
1196
+
882
1197
  _resolve(slug, res) {
883
1198
  const R = this._resources.get(slug);
884
1199
  if (!R) {
@@ -888,25 +1203,27 @@ class Admin {
888
1203
  return R;
889
1204
  }
890
1205
 
891
- _error(res, err) {
892
- const status = err.status || 500;
893
- res.status(status).send(`
894
- <html><body style="font-family:'DM Sans',system-ui,sans-serif;padding:48px;background:#f4f5f7;color:#111827">
895
- <div style="max-width:640px;margin:0 auto">
896
- <div style="display:flex;align-items:center;gap:10px;margin-bottom:24px">
897
- <div style="width:36px;height:36px;background:#fef2f2;border-radius:8px;display:flex;align-items:center;justify-content:center">
898
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
899
- </div>
900
- <h2 style="font-size:17px;font-weight:600;color:#dc2626">Admin Error ${status}</h2>
901
- </div>
902
- <pre style="background:#fff;border:1px solid #e3e6ec;padding:20px;border-radius:8px;color:#374151;font-size:12.5px;overflow-x:auto;line-height:1.6">${err.stack || err.message}</pre>
903
- <a href="javascript:history.back()" style="display:inline-flex;align-items:center;gap:6px;margin-top:16px;color:#2563eb;font-size:13px;text-decoration:none">
904
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
905
- Go back
906
- </a>
907
- </div>
908
- </body></html>
909
- `);
1206
+ _error(req, res, err) {
1207
+ const status = err.status || 500;
1208
+ const is404 = status === 404;
1209
+ const title = is404 ? 'Not found' : `Error ${status}`;
1210
+ const message = err.message || 'An unexpected error occurred.';
1211
+ const stack = process.env.NODE_ENV !== 'production' && !is404 ? (err.stack || '') : '';
1212
+
1213
+ try {
1214
+ const ctx = this._ctxWithFlash(req, res, {
1215
+ pageTitle: title,
1216
+ errorStatus: status,
1217
+ errorTitle: title,
1218
+ errorMsg: message,
1219
+ errorStack: stack,
1220
+ });
1221
+ res.status(status);
1222
+ return this._render(req, res, 'pages/error.njk', ctx);
1223
+ } catch (_renderErr) {
1224
+ // Fallback if template itself fails
1225
+ res.status(status).send(`<pre>${message}</pre>`);
1226
+ }
910
1227
  }
911
1228
 
912
1229
  // ─── Flash (cookie-based) ─────────────────────────────────────────────────
@@ -942,4 +1259,4 @@ module.exports.Admin = Admin;
942
1259
  module.exports.AdminResource = AdminResource;
943
1260
  module.exports.AdminField = AdminField;
944
1261
  module.exports.AdminFilter = AdminFilter;
945
- module.exports.AdminInline = AdminInline;
1262
+ module.exports.AdminInline = AdminInline;