millas 0.2.12-beta → 0.2.12-beta-2

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 (116) hide show
  1. package/package.json +3 -16
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +400 -167
  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 +309 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +383 -97
  12. package/src/admin/static/admin.css +1341 -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 +65 -1013
  19. package/src/admin/views/pages/detail.njk +40 -16
  20. package/src/admin/views/pages/form.njk +47 -599
  21. package/src/admin/views/pages/list.njk +145 -62
  22. package/src/admin/views/partials/form-field.njk +53 -0
  23. package/src/admin/views/partials/form-footer.njk +28 -0
  24. package/src/admin/views/partials/form-readonly.njk +114 -0
  25. package/src/admin/views/partials/form-scripts.njk +476 -0
  26. package/src/admin/views/partials/form-widget.njk +296 -0
  27. package/src/admin/views/partials/json-dialog.njk +80 -0
  28. package/src/admin/views/partials/json-editor.njk +37 -0
  29. package/src/admin.zip +0 -0
  30. package/src/auth/Auth.js +31 -10
  31. package/src/auth/AuthController.js +3 -1
  32. package/src/auth/AuthUser.js +119 -0
  33. package/src/cli.js +4 -2
  34. package/src/commands/createsuperuser.js +254 -0
  35. package/src/commands/lang.js +589 -0
  36. package/src/commands/migrate.js +154 -81
  37. package/src/commands/serve.js +82 -110
  38. package/src/container/AppInitializer.js +215 -0
  39. package/src/container/Application.js +278 -253
  40. package/src/container/HttpServer.js +156 -0
  41. package/src/container/MillasApp.js +29 -279
  42. package/src/container/MillasConfig.js +192 -0
  43. package/src/core/admin.js +5 -0
  44. package/src/core/auth.js +9 -0
  45. package/src/core/db.js +9 -0
  46. package/src/core/foundation.js +59 -0
  47. package/src/core/http.js +11 -0
  48. package/src/core/lang.js +1 -0
  49. package/src/core/mail.js +6 -0
  50. package/src/core/queue.js +7 -0
  51. package/src/core/validation.js +29 -0
  52. package/src/facades/Admin.js +1 -1
  53. package/src/facades/Auth.js +22 -39
  54. package/src/facades/Cache.js +21 -10
  55. package/src/facades/Database.js +1 -1
  56. package/src/facades/Events.js +18 -17
  57. package/src/facades/Facade.js +197 -0
  58. package/src/facades/Http.js +42 -45
  59. package/src/facades/Log.js +25 -49
  60. package/src/facades/Mail.js +27 -32
  61. package/src/facades/Queue.js +22 -15
  62. package/src/facades/Storage.js +18 -10
  63. package/src/facades/Url.js +53 -0
  64. package/src/http/HttpClient.js +673 -0
  65. package/src/http/ResponseDispatcher.js +18 -111
  66. package/src/http/UrlGenerator.js +375 -0
  67. package/src/http/WelcomePage.js +273 -0
  68. package/src/http/adapters/ExpressAdapter.js +315 -0
  69. package/src/http/adapters/HttpAdapter.js +168 -0
  70. package/src/http/adapters/index.js +9 -0
  71. package/src/i18n/I18nServiceProvider.js +91 -0
  72. package/src/i18n/Translator.js +635 -0
  73. package/src/i18n/defaults.js +122 -0
  74. package/src/i18n/index.js +164 -0
  75. package/src/i18n/locales/en.js +55 -0
  76. package/src/i18n/locales/sw.js +48 -0
  77. package/src/index.js +5 -144
  78. package/src/logger/formatters/PrettyFormatter.js +103 -57
  79. package/src/logger/internal.js +2 -2
  80. package/src/logger/patchConsole.js +91 -81
  81. package/src/middleware/MiddlewareRegistry.js +62 -82
  82. package/src/migrations/system/0001_users.js +21 -0
  83. package/src/migrations/system/0002_admin_log.js +25 -0
  84. package/src/migrations/system/0003_sessions.js +23 -0
  85. package/src/orm/fields/index.js +210 -188
  86. package/src/orm/migration/DefaultValueParser.js +325 -0
  87. package/src/orm/migration/InteractiveResolver.js +191 -0
  88. package/src/orm/migration/Makemigrations.js +312 -0
  89. package/src/orm/migration/MigrationGraph.js +227 -0
  90. package/src/orm/migration/MigrationRunner.js +202 -108
  91. package/src/orm/migration/MigrationWriter.js +463 -0
  92. package/src/orm/migration/ModelInspector.js +412 -344
  93. package/src/orm/migration/ModelScanner.js +225 -0
  94. package/src/orm/migration/ProjectState.js +213 -0
  95. package/src/orm/migration/RenameDetector.js +175 -0
  96. package/src/orm/migration/SchemaBuilder.js +8 -81
  97. package/src/orm/migration/operations/base.js +57 -0
  98. package/src/orm/migration/operations/column.js +191 -0
  99. package/src/orm/migration/operations/fields.js +252 -0
  100. package/src/orm/migration/operations/index.js +55 -0
  101. package/src/orm/migration/operations/models.js +152 -0
  102. package/src/orm/migration/operations/registry.js +131 -0
  103. package/src/orm/migration/operations/special.js +51 -0
  104. package/src/orm/migration/utils.js +208 -0
  105. package/src/orm/model/Model.js +81 -13
  106. package/src/providers/AdminServiceProvider.js +66 -9
  107. package/src/providers/AuthServiceProvider.js +46 -7
  108. package/src/providers/CacheStorageServiceProvider.js +5 -3
  109. package/src/providers/DatabaseServiceProvider.js +3 -2
  110. package/src/providers/EventServiceProvider.js +2 -1
  111. package/src/providers/LogServiceProvider.js +7 -3
  112. package/src/providers/MailServiceProvider.js +4 -3
  113. package/src/providers/QueueServiceProvider.js +4 -3
  114. package/src/router/Router.js +119 -152
  115. package/src/scaffold/templates.js +83 -26
  116. package/src/facades/Validation.js +0 -69
@@ -2,7 +2,10 @@
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');
8
11
 
@@ -79,6 +82,19 @@ class Admin {
79
82
  const prefix = this._config.prefix;
80
83
  this._njk = this._setupNunjucks(expressApp);
81
84
 
85
+ // ── Static assets ────────────────────────────────────────────────────────
86
+ // Serve ui.js from the admin source directory as a static file.
87
+ // Loaded by base.njk as /admin/static/ui.js
88
+ // Serve all files from src/admin/static/ at /admin/static/*
89
+ const _staticPath = require('path').join(__dirname, 'static');
90
+ expressApp.use(prefix + '/static', require('express').static(_staticPath, {
91
+ maxAge: '1h',
92
+ setHeaders(res, filePath) {
93
+ if (filePath.endsWith('.js')) res.setHeader('Content-Type', 'application/javascript');
94
+ if (filePath.endsWith('.css')) res.setHeader('Content-Type', 'text/css');
95
+ },
96
+ }));
97
+
82
98
  // ── Auth middleware (runs before all admin routes) ──────────
83
99
  expressApp.use(prefix, AdminAuth.middleware(prefix));
84
100
 
@@ -107,6 +123,17 @@ class Admin {
107
123
  expressApp.post (`${prefix}/:resource/bulk-action`, (q, s) => this._bulkAction(q, s));
108
124
  expressApp.post (`${prefix}/:resource/:id/action/:action`,(q, s) => this._rowAction(q, s));
109
125
 
126
+ // ── Relationship API ─────────────────────────────────────────────────────
127
+ // Used by FK and M2M widgets to fetch options via autocomplete.
128
+ // Returns JSON: [{ id, label }, ...]
129
+ expressApp.get(`${prefix}/api/:resource/options`, (q, s) => this._apiOptions(q, s));
130
+
131
+ // ── Inline CRUD routes ───────────────────────────────────────────────────
132
+ // Inline create: POST /admin/:resource/:id/inline/:inlineIndex
133
+ // Inline delete: POST /admin/:resource/:id/inline/:inlineIndex/:rowId/delete
134
+ expressApp.post(`${prefix}/:resource/:id/inline/:inlineIndex`, (q, s) => this._inlineStore(q, s));
135
+ expressApp.post(`${prefix}/:resource/:id/inline/:inlineIndex/:rowId/delete`, (q, s) => this._inlineDestroy(q, s));
136
+
110
137
  return this;
111
138
  }
112
139
 
@@ -154,6 +181,14 @@ class Admin {
154
181
  return `<code class="cell-mono">${JSON.stringify(value).slice(0, 40)}…</code>`;
155
182
  case 'email':
156
183
  return `<a href="mailto:${value}" style="color:var(--primary);text-decoration:none">${value}</a>`;
184
+ case 'url':
185
+ return `<a href="${value}" target="_blank" rel="noopener" style="color:var(--primary);text-decoration:none;word-break:break-all">${value}</a>`;
186
+ case 'phone':
187
+ return `<a href="tel:${value}" style="color:var(--primary);text-decoration:none">${value}</a>`;
188
+ case 'color':
189
+ 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>`;
190
+ case 'richtext':
191
+ 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>`;
157
192
  default: {
158
193
  const str = String(value);
159
194
  return str.length > 60
@@ -201,6 +236,13 @@ class Admin {
201
236
  } catch { return String(value); }
202
237
  case 'richtext':
203
238
  return `<div style="line-height:1.6;color:var(--text-soft)">${value}</div>`;
239
+ case 'phone':
240
+ return `<a href="tel:${value}" style="color:var(--primary)">${value}</a>`;
241
+ case 'badge': {
242
+ const colorMap2 = { admin:'purple', user:'blue', active:'green', inactive:'gray', pending:'yellow', published:'green', draft:'gray', banned:'red' };
243
+ const c2 = (field.colors && field.colors[String(value)]) || colorMap2[String(value)] || 'gray';
244
+ return `<span class="badge badge-${c2}">${value}</span>`;
245
+ }
204
246
  default: {
205
247
  const str = String(value);
206
248
  return str;
@@ -213,6 +255,13 @@ class Admin {
213
255
 
214
256
  env.addFilter('min', (arr) => Math.min(...arr));
215
257
 
258
+ // tabId: convert a tab name to a CSS/jQuery safe id fragment.
259
+ // Strips everything that is not alphanumeric, underscore, or hyphen.
260
+ // 'Role & Access' → 'Role--Access', 'Details' → 'Details'
261
+ env.addFilter('tabId', (name) =>
262
+ String(name).replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''));
263
+
264
+
216
265
  env.addFilter('relativeTime', (iso) => {
217
266
  try {
218
267
  const diff = Date.now() - new Date(iso).getTime();
@@ -231,18 +280,21 @@ class Admin {
231
280
 
232
281
  _ctx(req, extra = {}) {
233
282
  return {
283
+ csrfToken: AdminAuth.enabled ? AdminAuth.csrfToken(req) : 'disabled',
234
284
  adminPrefix: this._config.prefix,
235
285
  adminTitle: this._config.title,
236
286
  adminUser: req.adminUser || null,
237
287
  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
- })),
288
+ resources: this.resources()
289
+ .filter(r => r.hasPermission(req.adminUser || null, 'view'))
290
+ .map((r, idx) => ({
291
+ slug: r.slug,
292
+ label: r._getLabel(),
293
+ singular: r._getLabelSingular(),
294
+ icon: r.icon,
295
+ canView: r.hasPermission(req.adminUser || null, 'view'),
296
+ index: idx + 1,
297
+ })),
246
298
  flash: extra._flash || {},
247
299
  activePage: extra.activePage || null,
248
300
  activeResource: extra.activeResource || null,
@@ -258,15 +310,12 @@ class Admin {
258
310
 
259
311
  async _loginPage(req, res) {
260
312
  // 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
- }
313
+ if (AdminAuth.enabled && AdminAuth._getSession(req)) {
314
+ return res.redirect((req.query.next && decodeURIComponent(req.query.next)) || this._config.prefix + '/');
266
315
  }
267
316
 
268
317
  const flash = AdminAuth.getFlash(req, res);
269
- res.render('pages/login.njk', {
318
+ return this._render(req, res, 'pages/login.njk', {
270
319
  adminTitle: this._config.title,
271
320
  adminPrefix: this._config.prefix,
272
321
  flash,
@@ -292,7 +341,7 @@ class Admin {
292
341
 
293
342
  res.redirect(next || prefix + '/');
294
343
  } catch (err) {
295
- res.render('pages/login.njk', {
344
+ return this._render(req, res, 'pages/login.njk', {
296
345
  adminTitle: this._config.title,
297
346
  adminPrefix: prefix,
298
347
  flash: {},
@@ -344,10 +393,12 @@ class Admin {
344
393
  })
345
394
  );
346
395
 
347
- const activityData = ActivityLog.recent(25);
348
- const activityTotals = ActivityLog.totals();
396
+ const [activityData, activityTotals] = await Promise.all([
397
+ ActivityLog.recent(25),
398
+ ActivityLog.totals(),
399
+ ]);
349
400
 
350
- res.render('pages/dashboard.njk', this._ctxWithFlash(req, res, {
401
+ return this._render(req, res, 'pages/dashboard.njk', this._ctxWithFlash(req, res, {
351
402
  pageTitle: 'Dashboard',
352
403
  activePage: 'dashboard',
353
404
  resources: resourceData,
@@ -364,15 +415,17 @@ class Admin {
364
415
  const R = this._resolve(req.params.resource, res);
365
416
  if (!R) return;
366
417
 
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;
418
+ // Parse query params
419
+ const query = {
420
+ page: Number(req.query.page) || 1,
421
+ search: req.query.search || '',
422
+ sort: req.query.sort || 'id',
423
+ order: req.query.order || 'desc',
424
+ perPage:Number(req.query.perPage) || R.perPage,
425
+ year: req.query.year || null,
426
+ month: req.query.month || null,
427
+ };
374
428
 
375
- // Collect active filters
376
429
  const activeFilters = {};
377
430
  if (req.query.filter) {
378
431
  for (const [k, v] of Object.entries(req.query.filter)) {
@@ -380,46 +433,21 @@ class Admin {
380
433
  }
381
434
  }
382
435
 
383
- const result = await R.fetchList({ page, search, sort, order, perPage, filters: activeFilters, year, month });
436
+ const result = await R.fetchList({ ...query, filters: activeFilters });
384
437
  const rows = result.data.map(r => r.toJSON ? r.toJSON() : r);
385
438
 
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
- }));
439
+ const perms = {
440
+ canCreate: this._perm(R, 'add', req.adminUser),
441
+ canEdit: this._perm(R, 'change', req.adminUser),
442
+ canDelete: this._perm(R, 'delete', req.adminUser),
443
+ canView: this._perm(R, 'view', req.adminUser),
444
+ };
445
+
446
+ return this._render(req, res, 'pages/list.njk',
447
+ ViewContext.list(R, {
448
+ rows, result, query, activeFilters, perms,
449
+ baseCtx: this._ctxWithFlash(req, res, {}),
450
+ }), R);
423
451
  } catch (err) {
424
452
  this._error(res, err);
425
453
  }
@@ -429,18 +457,13 @@ class Admin {
429
457
  try {
430
458
  const R = this._resolve(req.params.resource, res);
431
459
  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
- }));
460
+ if (!this._perm(R, 'add', req.adminUser)) return res.status(403).send('You do not have permission to add ${R._getLabelSingular()} records.');
461
+
462
+ return this._render(req, res, 'pages/form.njk',
463
+ ViewContext.create(R, {
464
+ adminPrefix: this._config.prefix,
465
+ baseCtx: this._ctxWithFlash(req, res, {}),
466
+ }), R);
444
467
  } catch (err) {
445
468
  this._error(res, err);
446
469
  }
@@ -450,10 +473,11 @@ class Admin {
450
473
  try {
451
474
  const R = this._resolve(req.params.resource, res);
452
475
  if (!R) return;
453
- if (!R.canCreate) return res.status(403).send('Not allowed');
476
+ if (!this._perm(R, 'add', req.adminUser)) return res.status(403).send('You do not have permission to add ${R._getLabelSingular()} records.');
477
+ if (!this._verifyCsrf(req, res)) return;
454
478
 
455
- const record = await R.create(req.body);
456
- ActivityLog.record('create', R.slug, record?.id, `New ${R._getLabelSingular()}`);
479
+ const record = await R.create(req.body, { user: req.adminUser, resource: R });
480
+ ActivityLog.record('create', R.slug, record?.id, `New ${R._getLabelSingular()}`, req.adminUser);
457
481
 
458
482
  const submit = req.body._submit || 'save';
459
483
  if (submit === 'continue' && record?.id) {
@@ -470,16 +494,13 @@ class Admin {
470
494
  } catch (err) {
471
495
  if (err.status === 422) {
472
496
  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
- }));
497
+ return this._render(req, res, 'pages/form.njk',
498
+ ViewContext.create(R, {
499
+ adminPrefix: this._config.prefix,
500
+ record: req.body,
501
+ errors: err.errors || {},
502
+ baseCtx: this._ctxWithFlash(req, res, {}),
503
+ }), R);
483
504
  }
484
505
  this._error(res, err);
485
506
  }
@@ -489,21 +510,19 @@ class Admin {
489
510
  try {
490
511
  const R = this._resolve(req.params.resource, res);
491
512
  if (!R) return;
492
- if (!R.canEdit) return res.status(403).send('Not allowed');
513
+ if (!this._perm(R, 'change', req.adminUser)) return res.status(403).send('You do not have permission to change ${R._getLabelSingular()} records.');
493
514
 
494
515
  const record = await R.fetchOne(req.params.id);
495
516
  const data = record.toJSON ? record.toJSON() : record;
496
517
 
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
- }));
518
+ return this._render(req, res, 'pages/form.njk',
519
+ ViewContext.edit(R, {
520
+ adminPrefix: this._config.prefix,
521
+ id: req.params.id,
522
+ record: data,
523
+ canDelete: this._perm(R, 'delete', req.adminUser),
524
+ baseCtx: this._ctxWithFlash(req, res, {}),
525
+ }), R);
507
526
  } catch (err) {
508
527
  this._error(res, err);
509
528
  }
@@ -513,13 +532,14 @@ class Admin {
513
532
  try {
514
533
  const R = this._resolve(req.params.resource, res);
515
534
  if (!R) return;
516
- if (!R.canEdit) return res.status(403).send('Not allowed');
535
+ if (!this._perm(R, 'change', req.adminUser)) return res.status(403).send('You do not have permission to change ${R._getLabelSingular()} records.');
536
+ if (!this._verifyCsrf(req, res)) return;
517
537
 
518
538
  // Support method override
519
539
  const method = req.body._method || 'POST';
520
540
  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}`);
541
+ await R.update(req.params.id, req.body, { user: req.adminUser, resource: R });
542
+ ActivityLog.record('update', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`, req.adminUser);
523
543
 
524
544
  const submit = req.body._submit || 'save';
525
545
  if (submit === 'continue') {
@@ -533,16 +553,15 @@ class Admin {
533
553
  } catch (err) {
534
554
  if (err.status === 422) {
535
555
  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
- }));
556
+ return this._render(req, res, 'pages/form.njk',
557
+ ViewContext.edit(R, {
558
+ adminPrefix: this._config.prefix,
559
+ id: req.params.id,
560
+ record: { id: req.params.id, ...req.body },
561
+ canDelete: this._perm(R, 'delete', req.adminUser),
562
+ errors: err.errors || {},
563
+ baseCtx: this._ctxWithFlash(req, res, {}),
564
+ }), R);
546
565
  }
547
566
  this._error(res, err);
548
567
  }
@@ -552,10 +571,11 @@ class Admin {
552
571
  try {
553
572
  const R = this._resolve(req.params.resource, res);
554
573
  if (!R) return;
555
- if (!R.canDelete) return res.status(403).send('Not allowed');
574
+ if (!this._perm(R, 'delete', req.adminUser)) return res.status(403).send('You do not have permission to delete ${R._getLabelSingular()} records.');
575
+ if (!this._verifyCsrf(req, res)) return;
556
576
 
557
- await R.destroy(req.params.id);
558
- ActivityLog.record('delete', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`);
577
+ await R.destroy(req.params.id, { user: req.adminUser, resource: R });
578
+ ActivityLog.record('delete', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`, req.adminUser);
559
579
  this._flash(req, 'success', `${R._getLabelSingular()} deleted`);
560
580
  this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
561
581
  } catch (err) {
@@ -569,47 +589,34 @@ class Admin {
569
589
  try {
570
590
  const R = this._resolve(req.params.resource, res);
571
591
  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');
592
+ if (!this._perm(R, 'view', req.adminUser)) {
593
+ if (this._perm(R, 'change', req.adminUser)) return res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}/edit`);
594
+ return res.status(403).send('You do not have permission to view ${R._getLabelSingular()} records.');
575
595
  }
576
596
 
577
597
  const record = await R.fetchOne(req.params.id);
578
598
  const data = record.toJSON ? record.toJSON() : record;
579
599
 
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
600
  // Load inline related records
587
601
  const inlineData = await Promise.all(
588
- (R.inlines || []).map(async (inline) => {
602
+ (R.inlines || []).map(async (inline, idx) => {
589
603
  const rows = await inline.fetchRows(data[R.model.primaryKey || 'id']);
590
- return { ...inline.toJSON(), rows };
604
+ return { ...inline.toJSON(), rows, inlineIndex: idx };
591
605
  })
592
606
  );
593
607
 
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
- }));
608
+ return this._render(req, res, 'pages/detail.njk',
609
+ ViewContext.detail(R, {
610
+ id: req.params.id,
611
+ record: data,
612
+ inlineData,
613
+ perms: {
614
+ canEdit: this._perm(R, 'change', req.adminUser),
615
+ canDelete: this._perm(R, 'delete', req.adminUser),
616
+ canCreate: this._perm(R, 'add', req.adminUser),
617
+ },
618
+ baseCtx: this._ctxWithFlash(req, res, {}),
619
+ }), R);
613
620
  } catch (err) {
614
621
  this._error(res, err);
615
622
  }
@@ -621,7 +628,8 @@ class Admin {
621
628
  try {
622
629
  const R = this._resolve(req.params.resource, res);
623
630
  if (!R) return;
624
- if (!R.canDelete) return res.status(403).send('Not allowed');
631
+ if (!this._perm(R, 'delete', req.adminUser)) return res.status(403).send('You do not have permission to delete ${R._getLabelSingular()} records.');
632
+ if (!this._verifyCsrf(req, res)) return;
625
633
 
626
634
  const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids].filter(Boolean);
627
635
  if (!ids.length) {
@@ -630,7 +638,7 @@ class Admin {
630
638
  }
631
639
 
632
640
  await R.model.destroy(...ids);
633
- ActivityLog.record('delete', R.slug, null, `${ids.length} ${R._getLabel()} (bulk)`);
641
+ ActivityLog.record('delete', R.slug, null, `${ids.length} ${R._getLabel()} (bulk)`, req.adminUser);
634
642
  this._flash(req, 'success', `Deleted ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
635
643
  this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
636
644
  } catch (err) {
@@ -645,6 +653,7 @@ class Admin {
645
653
  const R = this._resolve(req.params.resource, res);
646
654
  if (!R) return;
647
655
 
656
+ if (!this._verifyCsrf(req, res)) return;
648
657
  const actionIndex = Number(req.body.actionIndex);
649
658
  const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids].filter(Boolean);
650
659
  const action = (R.actions || [])[actionIndex];
@@ -660,7 +669,7 @@ class Admin {
660
669
  }
661
670
 
662
671
  await action.handler(ids, R.model);
663
- ActivityLog.record('update', R.slug, null, `Bulk action "${action.label}" on ${ids.length} records`);
672
+ ActivityLog.record('update', R.slug, null, `Bulk action "${action.label}" on ${ids.length} records`, req.adminUser);
664
673
  this._flash(req, 'success', `Action "${action.label}" applied to ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
665
674
  this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
666
675
  } catch (err) {
@@ -675,6 +684,7 @@ class Admin {
675
684
  const R = this._resolve(req.params.resource, res);
676
685
  if (!R) return;
677
686
 
687
+ if (!this._verifyCsrf(req, res)) return;
678
688
  const actionName = req.params.action;
679
689
  const rowAction = (R.rowActions || []).find(a => a.action === actionName);
680
690
 
@@ -690,7 +700,7 @@ class Admin {
690
700
  ? result
691
701
  : `${this._config.prefix}/${R.slug}`;
692
702
 
693
- ActivityLog.record('update', R.slug, req.params.id, `Action "${rowAction.label}" on #${req.params.id}`);
703
+ ActivityLog.record('update', R.slug, req.params.id, `Action "${rowAction.label}" on #${req.params.id}`, req.adminUser);
694
704
  this._flash(req, 'success', rowAction.successMessage || `Action "${rowAction.label}" completed.`);
695
705
  this._redirectWithFlash(res, redirect, req._flashType, req._flashMessage);
696
706
  } catch (err) {
@@ -698,18 +708,172 @@ class Admin {
698
708
  }
699
709
  }
700
710
 
711
+ // ─── Relationship API ────────────────────────────────────────────────────────
712
+
713
+ /**
714
+ * GET /admin/api/:resource/options?q=search&limit=20
715
+ *
716
+ * Returns a JSON array of { id, label } objects for use by FK and M2M
717
+ * widgets in autocomplete selects. The label is derived from the first
718
+ * searchable column on the resource, or falls back to the primary key.
719
+ *
720
+ * Called by the frontend widget via fetch() — no page reload needed.
721
+ */
722
+ async _apiOptions(req, res) {
723
+ try {
724
+ // Look up resource by slug first, then fall back to table name.
725
+ // fkResource on a field is the table name (e.g. 'users') which usually
726
+ // matches the resource slug — but if the developer registered with a
727
+ // custom label the slug may differ. Table-name fallback catches that.
728
+ const slug = req.params.resource;
729
+ let R = this._resources.get(slug);
730
+ if (!R) {
731
+ // Fall back: find a resource whose model.table matches the slug
732
+ for (const resource of this._resources.values()) {
733
+ if (resource.model && resource.model.table === slug) { R = resource; break; }
734
+ }
735
+ }
736
+ if (!R) return res.status(404).json({ error: `Resource "${slug}" not found` });
737
+ if (!R.hasPermission(req.adminUser || null, 'view')) {
738
+ return res.status(403).json({ error: 'Forbidden' });
739
+ }
740
+
741
+ const search = (req.query.q || '').trim();
742
+ const page = Math.max(1, Number(req.query.page) || 1);
743
+ const perPage = Math.min(Number(req.query.limit) || 20, 100);
744
+ const offset = (page - 1) * perPage;
745
+
746
+ const pk = R.model.primaryKey || 'id';
747
+
748
+ // Label column resolution — priority order:
749
+ // 1. resource.fkLabel explicitly set (developer override)
750
+ // 2. resource.searchable[0] — first searchable column
751
+ // 3. Auto-detect from model fields: name > email > title > label > first string field
752
+ // 4. pk as last resort (gives id as label which is unhelpful but safe)
753
+ let labelCol = R.fkLabel || (R.searchable && R.searchable[0]) || null;
754
+ if (!labelCol && R.model) {
755
+ const fields = typeof R.model.getFields === 'function'
756
+ ? R.model.getFields()
757
+ : (R.model.fields || {});
758
+ const preferred = ['name', 'email', 'title', 'label', 'full_name',
759
+ 'fullname', 'username', 'display_name', 'first_name'];
760
+ for (const p of preferred) {
761
+ if (fields[p]) { labelCol = p; break; }
762
+ }
763
+ if (!labelCol) {
764
+ // First string field that isn't a password/token
765
+ const skip = new Set(['password', 'token', 'secret', 'hash', 'remember_token']);
766
+ for (const [col, def] of Object.entries(fields)) {
767
+ if (def.type === 'string' && !skip.has(col) && col !== pk) {
768
+ labelCol = col; break;
769
+ }
770
+ }
771
+ }
772
+ }
773
+ labelCol = labelCol || pk;
774
+
775
+ // Call _db() separately for each query — knex builders are mutable,
776
+ // reusing the same instance across count + select corrupts both queries.
777
+ let countQ = R.model._db().count(`${pk} as total`);
778
+ if (search) countQ = countQ.where(labelCol, 'like', `%${search}%`);
779
+ const [{ total }] = await countQ;
780
+
781
+ let rowQ = R.model._db()
782
+ .select([`${pk} as id`, `${labelCol} as label`])
783
+ .orderBy(labelCol, 'asc')
784
+ .limit(perPage)
785
+ .offset(offset);
786
+ if (search) rowQ = rowQ.where(labelCol, 'like', `%${search}%`);
787
+ const rows = await rowQ;
788
+
789
+ return res.json({
790
+ data: rows,
791
+ total: Number(total),
792
+ page,
793
+ perPage,
794
+ hasMore: offset + rows.length < Number(total),
795
+ labelCol, // lets the frontend show "Search by <field>…" in the placeholder
796
+ });
797
+ } catch (err) {
798
+ return res.status(500).json({ error: err.message });
799
+ }
800
+ }
801
+
802
+ // ─── Inline CRUD ──────────────────────────────────────────────────────────────
803
+
804
+ /**
805
+ * POST /admin/:resource/:id/inline/:inlineIndex
806
+ *
807
+ * Create a new inline related record.
808
+ * The inlineIndex identifies which AdminInline in R.inlines[] to use.
809
+ */
810
+ async _inlineStore(req, res) {
811
+ try {
812
+ const R = this._resolve(req.params.resource, res);
813
+ if (!R) return;
814
+ if (!this._verifyCsrf(req, res)) return;
815
+
816
+ const idx = Number(req.params.inlineIndex);
817
+ const inline = (R.inlines || [])[idx];
818
+ if (!inline) return res.status(404).send('Inline not found.');
819
+ if (!inline.canCreate) return res.status(403).send('Inline create is disabled.');
820
+
821
+ // Inject the FK value from the parent record ID
822
+ const data = {
823
+ ...req.body,
824
+ [inline.foreignKey]: req.params.id,
825
+ };
826
+ // Strip system fields
827
+ delete data._csrf;
828
+ delete data._method;
829
+ delete data._submit;
830
+
831
+ await inline.model.create(data);
832
+ ActivityLog.record('create', inline.label, null, `Inline ${inline.label} for #${req.params.id}`, req.adminUser);
833
+
834
+ AdminAuth.setFlash(res, 'success', `${inline.label} added.`);
835
+ res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}`);
836
+ } catch (err) {
837
+ this._error(res, err);
838
+ }
839
+ }
840
+
841
+ /**
842
+ * POST /admin/:resource/:id/inline/:inlineIndex/:rowId/delete
843
+ *
844
+ * Delete an inline related record.
845
+ */
846
+ async _inlineDestroy(req, res) {
847
+ try {
848
+ const R = this._resolve(req.params.resource, res);
849
+ if (!R) return;
850
+ if (!this._verifyCsrf(req, res)) return;
851
+
852
+ const idx = Number(req.params.inlineIndex);
853
+ const inline = (R.inlines || [])[idx];
854
+ if (!inline) return res.status(404).send('Inline not found.');
855
+ if (!inline.canDelete) return res.status(403).send('Inline delete is disabled.');
856
+
857
+ await inline.model.destroy(req.params.rowId);
858
+ ActivityLog.record('delete', inline.label, req.params.rowId, `Inline ${inline.label} #${req.params.rowId}`, req.adminUser);
859
+
860
+ AdminAuth.setFlash(res, 'success', 'Record deleted.');
861
+ res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}`);
862
+ } catch (err) {
863
+ this._error(res, err);
864
+ }
865
+ }
866
+
701
867
  async _search(req, res) {
702
868
  try {
703
869
  const q = (req.query.q || '').trim();
704
870
 
705
871
  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
- }));
872
+ return this._render(req, res, 'pages/search.njk',
873
+ ViewContext.search({
874
+ query: '', results: [], total: 0,
875
+ baseCtx: this._ctxWithFlash(req, res, { activePage: 'search' }),
876
+ }));
713
877
  }
714
878
 
715
879
  const results = await Promise.all(
@@ -737,13 +901,11 @@ class Admin {
737
901
  const filtered = results.filter(Boolean);
738
902
  const total = filtered.reduce((s, r) => s + r.total, 0);
739
903
 
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
- }));
904
+ return this._render(req, res, 'pages/search.njk',
905
+ ViewContext.search({
906
+ query: q, results: filtered, total,
907
+ baseCtx: this._ctxWithFlash(req, res, { activePage: 'search' }),
908
+ }));
747
909
  } catch (err) {
748
910
  this._error(res, err);
749
911
  }
@@ -846,7 +1008,6 @@ class Admin {
846
1008
 
847
1009
  result.push(json);
848
1010
  }
849
-
850
1011
  return result;
851
1012
  }
852
1013
 
@@ -879,6 +1040,78 @@ class Admin {
879
1040
  return tabs;
880
1041
  }
881
1042
 
1043
+ /**
1044
+ * Resolve a permission for a resource + user combination.
1045
+ * Single chokepoint — every action gate calls this.
1046
+ *
1047
+ * @param {class} R — AdminResource subclass
1048
+ * @param {string} action — 'view'|'add'|'change'|'delete'
1049
+ * @param {object} user — req.adminUser (may be null if auth disabled)
1050
+ */
1051
+ _perm(R, action, user) {
1052
+ return R.hasPermission(user || null, action);
1053
+ }
1054
+
1055
+ /**
1056
+ * Verify the CSRF token on a mutating request.
1057
+ * Checks both req.body._csrf and the X-CSRF-Token header.
1058
+ * Returns true if auth is disabled (non-browser clients).
1059
+ */
1060
+ /**
1061
+ * Render a template with before_render / after_render hooks.
1062
+ *
1063
+ * Replaces direct res.render() calls in every page handler so hooks
1064
+ * can inject extra template data or react after a page is sent.
1065
+ *
1066
+ * @param {object} req
1067
+ * @param {object} res
1068
+ * @param {string} template — e.g. 'pages/list.njk'
1069
+ * @param {object} ctx — template data
1070
+ * @param {class} Resource — AdminConfig subclass (may be null for auth pages)
1071
+ */
1072
+ async _render(req, res, template, ctx, Resource = null) {
1073
+ const start = Date.now();
1074
+
1075
+ // ── before_render ──────────────────────────────────────────────────
1076
+ let finalCtx = ctx;
1077
+ try {
1078
+ const hookCtx = await HookPipeline.run(
1079
+ 'before_render',
1080
+ { view: template, templateCtx: ctx, user: req.adminUser || null, resource: Resource },
1081
+ Resource,
1082
+ AdminHooks,
1083
+ );
1084
+ finalCtx = hookCtx.templateCtx || ctx;
1085
+ } catch (err) {
1086
+ // before_render errors abort the render — surface as a 500
1087
+ return this._error(res, err);
1088
+ }
1089
+
1090
+ // ── Render ──────────────────────────────────────────────────────────
1091
+ res.render(template, finalCtx);
1092
+
1093
+ // ── after_render (fire-and-forget) ───────────────────────────────────
1094
+ setImmediate(() => {
1095
+ HookPipeline.run(
1096
+ 'after_render',
1097
+ { view: template, user: req.adminUser || null, resource: Resource, ms: Date.now() - start },
1098
+ Resource,
1099
+ AdminHooks,
1100
+ ).catch(err => {
1101
+ process.stderr.write(`[AdminHooks] after_render error: ${err.message}
1102
+ `);
1103
+ });
1104
+ });
1105
+ }
1106
+
1107
+ _verifyCsrf(req, res) {
1108
+ if (!AdminAuth.enabled) return true;
1109
+ const token = req.body?._csrf || req.headers['x-csrf-token'];
1110
+ if (AdminAuth.verifyCsrf(req, token)) return true;
1111
+ res.status(403).send('CSRF token missing or invalid. Please reload the page and try again.');
1112
+ return false;
1113
+ }
1114
+
882
1115
  _resolve(slug, res) {
883
1116
  const R = this._resources.get(slug);
884
1117
  if (!R) {
@@ -942,4 +1175,4 @@ module.exports.Admin = Admin;
942
1175
  module.exports.AdminResource = AdminResource;
943
1176
  module.exports.AdminField = AdminField;
944
1177
  module.exports.AdminFilter = AdminFilter;
945
- module.exports.AdminInline = AdminInline;
1178
+ module.exports.AdminInline = AdminInline;