millas 0.2.13-beta → 0.2.14

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 (88) hide show
  1. package/package.json +6 -3
  2. package/src/admin/Admin.js +107 -1027
  3. package/src/admin/AdminAuth.js +1 -1
  4. package/src/admin/ViewContext.js +1 -1
  5. package/src/admin/handlers/ActionHandler.js +103 -0
  6. package/src/admin/handlers/ApiHandler.js +113 -0
  7. package/src/admin/handlers/AuthHandler.js +76 -0
  8. package/src/admin/handlers/ExportHandler.js +70 -0
  9. package/src/admin/handlers/InlineHandler.js +71 -0
  10. package/src/admin/handlers/PageHandler.js +351 -0
  11. package/src/admin/resources/AdminResource.js +22 -1
  12. package/src/admin/static/SelectFilter2.js +34 -0
  13. package/src/admin/static/actions.js +201 -0
  14. package/src/admin/static/admin.css +7 -0
  15. package/src/admin/static/change_form.js +585 -0
  16. package/src/admin/static/core.js +128 -0
  17. package/src/admin/static/login.js +76 -0
  18. package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
  19. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
  20. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
  21. package/src/admin/static/vendor/jquery.min.js +2 -0
  22. package/src/admin/views/layouts/base.njk +30 -113
  23. package/src/admin/views/pages/detail.njk +10 -9
  24. package/src/admin/views/pages/form.njk +4 -4
  25. package/src/admin/views/pages/list.njk +11 -193
  26. package/src/admin/views/pages/login.njk +19 -64
  27. package/src/admin/views/partials/form-field.njk +1 -1
  28. package/src/admin/views/partials/form-scripts.njk +4 -478
  29. package/src/admin/views/partials/form-widget.njk +10 -10
  30. package/src/ai/AITokenBudget.js +1 -1
  31. package/src/auth/Auth.js +112 -3
  32. package/src/auth/AuthMiddleware.js +18 -15
  33. package/src/auth/Hasher.js +15 -43
  34. package/src/cli.js +3 -0
  35. package/src/commands/call.js +190 -0
  36. package/src/commands/createsuperuser.js +3 -4
  37. package/src/commands/key.js +97 -0
  38. package/src/commands/make.js +16 -2
  39. package/src/commands/new.js +16 -1
  40. package/src/commands/serve.js +5 -5
  41. package/src/console/Command.js +337 -0
  42. package/src/console/CommandLoader.js +165 -0
  43. package/src/console/index.js +6 -0
  44. package/src/container/AppInitializer.js +48 -1
  45. package/src/container/Application.js +3 -1
  46. package/src/container/HttpServer.js +0 -1
  47. package/src/container/MillasConfig.js +48 -0
  48. package/src/controller/Controller.js +13 -11
  49. package/src/core/docs.js +6 -0
  50. package/src/core/foundation.js +8 -0
  51. package/src/core/http.js +20 -10
  52. package/src/core/validation.js +58 -27
  53. package/src/docs/Docs.js +268 -0
  54. package/src/docs/DocsServiceProvider.js +80 -0
  55. package/src/docs/SchemaInferrer.js +131 -0
  56. package/src/docs/handlers/ApiHandler.js +305 -0
  57. package/src/docs/handlers/PageHandler.js +47 -0
  58. package/src/docs/index.js +13 -0
  59. package/src/docs/resources/ApiResource.js +402 -0
  60. package/src/docs/static/docs.css +723 -0
  61. package/src/docs/static/docs.js +1181 -0
  62. package/src/encryption/Encrypter.js +381 -0
  63. package/src/facades/Auth.js +5 -2
  64. package/src/facades/Crypt.js +166 -0
  65. package/src/facades/Docs.js +43 -0
  66. package/src/facades/Mail.js +1 -1
  67. package/src/http/MillasRequest.js +7 -31
  68. package/src/http/RequestContext.js +11 -7
  69. package/src/http/SecurityBootstrap.js +24 -2
  70. package/src/http/Shape.js +168 -0
  71. package/src/http/adapters/ExpressAdapter.js +9 -5
  72. package/src/middleware/CorsMiddleware.js +3 -0
  73. package/src/middleware/ThrottleMiddleware.js +10 -7
  74. package/src/orm/model/Model.js +14 -1
  75. package/src/providers/EncryptionServiceProvider.js +66 -0
  76. package/src/router/MiddlewareRegistry.js +79 -54
  77. package/src/router/Route.js +9 -4
  78. package/src/router/RouteEntry.js +91 -0
  79. package/src/router/Router.js +71 -1
  80. package/src/scaffold/maker.js +138 -1
  81. package/src/scaffold/templates.js +12 -0
  82. package/src/serializer/Serializer.js +239 -0
  83. package/src/support/Str.js +1080 -0
  84. package/src/validation/BaseValidator.js +45 -5
  85. package/src/validation/Validator.js +67 -61
  86. package/src/validation/types.js +490 -0
  87. package/src/middleware/AuthMiddleware.js +0 -46
  88. package/src/middleware/MiddlewareRegistry.js +0 -106
@@ -4,55 +4,31 @@ const path = require('path');
4
4
  const nunjucks = require('nunjucks');
5
5
  const ActivityLog = require('./ActivityLog');
6
6
  const { HookPipeline, AdminHooks } = require('./HookRegistry');
7
- const { FormGenerator } = require('./FormGenerator');
8
- const { ViewContext } = require('./ViewContext');
9
7
  const AdminAuth = require('./AdminAuth');
10
8
  const { AdminResource, AdminField, AdminFilter, AdminInline } = require('./resources/AdminResource');
11
- const LookupParser = require('../orm/query/LookupParser');
12
9
  const Facade = require('../facades/Facade');
13
10
 
14
- /**
15
- * Admin
16
- *
17
- * The Millas admin panel.
18
- * Auto-mounts at /admin by default — no configuration needed.
19
- *
20
- * Basic usage (AppServiceProvider.boot):
21
- * Admin.register(UserResource);
22
- * Admin.mount(route, expressApp);
23
- *
24
- * Custom resource:
25
- * class UserResource extends AdminResource {
26
- * static model = User;
27
- * static label = 'Users';
28
- * static fields() { return [AdminField.id(), AdminField.text('name')]; }
29
- * }
30
- */
11
+ const AuthHandler = require('./handlers/AuthHandler');
12
+ const PageHandler = require('./handlers/PageHandler');
13
+ const ActionHandler = require('./handlers/ActionHandler');
14
+ const ApiHandler = require('./handlers/ApiHandler');
15
+ const InlineHandler = require('./handlers/InlineHandler');
16
+ const ExportHandler = require('./handlers/ExportHandler');
17
+
31
18
  class Admin {
32
19
  constructor() {
33
20
  this._resources = new Map();
34
- this._config = {
35
- prefix: '/admin',
36
- title: 'Millas Admin',
37
- };
38
- this._njk = null;
21
+ this._config = { prefix: '/admin', title: 'Millas Admin' };
22
+ this._njk = null;
39
23
  }
40
24
 
41
- // ─── Configuration ─────────────────────────────────────────────────────────
42
-
43
25
  configure(config = {}) {
26
+ if (config.prefix) config = { ...config, prefix: config.prefix.replace(/\/+$/, '') };
44
27
  Object.assign(this._config, config);
45
-
46
- // Initialise auth if configured
47
- if (config.auth !== undefined) {
48
- AdminAuth.configure(config.auth);
49
- }
50
-
28
+ if (config.auth !== undefined) AdminAuth.configure(config.auth);
51
29
  return this;
52
30
  }
53
31
 
54
- // ─── Registration ─────────────────────────────────────────────────────────
55
-
56
32
  register(ResourceOrModel) {
57
33
  let Resource = ResourceOrModel;
58
34
  if (ResourceOrModel.fields !== undefined &&
@@ -72,23 +48,11 @@ class Admin {
72
48
  return [...this._resources.values()];
73
49
  }
74
50
 
75
- // ─── Mount ────────────────────────────────────────────────────────────────
76
-
77
- /**
78
- * Mount all admin routes onto express directly.
79
- * Call this AFTER app.boot() in bootstrap/app.js:
80
- *
81
- * Admin.mount(expressApp);
82
- */
83
51
  mount(expressApp) {
84
52
  const prefix = this._config.prefix;
85
53
  this._njk = this._setupNunjucks(expressApp);
86
54
 
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');
55
+ const _staticPath = path.join(__dirname, 'static');
92
56
  expressApp.use(prefix + '/static', require('express').static(_staticPath, {
93
57
  maxAge: '1h',
94
58
  setHeaders(res, filePath) {
@@ -97,50 +61,47 @@ class Admin {
97
61
  },
98
62
  }));
99
63
 
100
- // ── Auth middleware (runs before all admin routes) ──────────
101
64
  expressApp.use(prefix, AdminAuth.middleware(prefix));
102
65
 
103
- // ── Login / logout ──────────────────────────────────────────
104
- expressApp.get (`${prefix}/login`, (q, s) => this._loginPage(q, s));
105
- expressApp.post(`${prefix}/login`, (q, s) => this._loginSubmit(q, s));
106
- expressApp.get (`${prefix}/logout`, (q, s) => this._logout(q, s));
66
+ const auth = new AuthHandler(this);
67
+ const page = new PageHandler(this);
68
+ const action = new ActionHandler(this);
69
+ const api = new ApiHandler(this);
70
+ const inline = new InlineHandler(this);
71
+ const exp = new ExportHandler(this);
72
+
73
+ expressApp.get (`${prefix}/login`, (q, s) => auth.loginPage(q, s));
74
+ expressApp.post(`${prefix}/login`, (q, s) => auth.loginSubmit(q, s));
75
+ expressApp.get (`${prefix}/logout`, (q, s) => auth.logout(q, s));
107
76
 
108
- // Dashboard
109
- expressApp.get(`${prefix}`, (q, s) => this._dashboard(q, s));
110
- expressApp.get(`${prefix}/`, (q, s) => this._dashboard(q, s));
77
+ expressApp.get(`${prefix}`, (q, s) => page.dashboard(q, s));
78
+ expressApp.get(`${prefix}/`, (q, s) => page.dashboard(q, s));
79
+ expressApp.get(`${prefix}/search`, (q, s) => page.search(q, s));
111
80
 
112
- // Global search
113
- expressApp.get(`${prefix}/search`, (q, s) => this._search(q, s));
81
+ expressApp.get(`${prefix}/api/:resource/options`, (q, s) => api.options(q, s));
114
82
 
115
- // Resource routes
116
- expressApp.get (`${prefix}/:resource`, (q, s) => this._list(q, s));
117
- expressApp.get (`${prefix}/:resource/export.:format`, (q, s) => this._export(q, s));
118
- expressApp.get (`${prefix}/:resource/create`, (q, s) => this._create(q, s));
119
- expressApp.post (`${prefix}/:resource`, (q, s) => this._store(q, s));
120
- expressApp.get (`${prefix}/:resource/:id/edit`, (q, s) => this._edit(q, s));
121
- expressApp.get (`${prefix}/:resource/:id`, (q, s) => this._detail(q, s));
122
- expressApp.post (`${prefix}/:resource/:id`, (q, s) => this._update(q, s));
123
- expressApp.post (`${prefix}/:resource/:id/delete`, (q, s) => this._destroy(q, s));
124
- expressApp.post (`${prefix}/:resource/bulk-delete`, (q, s) => this._bulkDestroy(q, s));
125
- expressApp.post (`${prefix}/:resource/bulk-action`, (q, s) => this._bulkAction(q, s));
126
- expressApp.post (`${prefix}/:resource/:id/action/:action`,(q, s) => this._rowAction(q, s));
83
+ expressApp.get (`${prefix}/:resource`, (q, s) => page.list(q, s));
84
+ expressApp.get (`${prefix}/:resource/export.:format`, (q, s) => exp.export(q, s));
85
+ expressApp.get (`${prefix}/:resource/create`, (q, s) => page.create(q, s));
86
+ expressApp.post (`${prefix}/:resource`, (q, s) => page.store(q, s));
127
87
 
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));
88
+ // ── Bulk actions — must come before /:resource/:id to avoid wildcard swallowing ──
89
+ expressApp.post(`${prefix}/:resource/bulk-delete`, (q, s) => action.bulkDestroy(q, s));
90
+ expressApp.post(`${prefix}/:resource/bulk-action`, (q, s) => action.bulkAction(q, s));
132
91
 
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));
92
+ expressApp.get (`${prefix}/:resource/:id/edit`, (q, s) => page.edit(q, s));
93
+ expressApp.get (`${prefix}/:resource/:id`, (q, s) => page.detail(q, s));
94
+ expressApp.post (`${prefix}/:resource/:id`, (q, s) => page.update(q, s));
95
+ expressApp.post (`${prefix}/:resource/:id/delete`, (q, s) => page.destroy(q, s));
96
+
97
+ expressApp.post(`${prefix}/:resource/:id/action/:action`,(q, s) => action.rowAction(q, s));
98
+
99
+ expressApp.post(`${prefix}/:resource/:id/inline/:inlineIndex`, (q, s) => inline.store(q, s));
100
+ expressApp.post(`${prefix}/:resource/:id/inline/:inlineIndex/:rowId/delete`, (q, s) => inline.destroy(q, s));
138
101
 
139
102
  return this;
140
103
  }
141
104
 
142
- // ─── Nunjucks setup ───────────────────────────────────────────────────────
143
-
144
105
  _setupNunjucks(expressApp) {
145
106
  const viewsDir = path.join(__dirname, 'views');
146
107
  const env = nunjucks.configure(viewsDir, {
@@ -149,9 +110,6 @@ class Admin {
149
110
  noCache: process.env.NODE_ENV !== 'production',
150
111
  });
151
112
 
152
- // ── Custom filters ───────────────────────────────────────────
153
-
154
- // Resolve a fkResource table name to the registered admin slug (or null)
155
113
  const resolveFkSlug = (tableName) => {
156
114
  if (!tableName) return null;
157
115
  if (this._resources.has(tableName)) return tableName;
@@ -169,129 +127,67 @@ class Admin {
169
127
  ? `<span class="bool-yes"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>`
170
128
  : `<span class="bool-no"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>`;
171
129
  case 'badge': {
172
- const colorMap = {
173
- admin:'purple', user:'blue', active:'green', inactive:'gray',
174
- pending:'yellow', published:'green', draft:'gray', banned:'red',
175
- true:'green', false:'gray', 1:'green', 0:'gray',
176
- };
130
+ const colorMap = { admin:'purple', user:'blue', active:'green', inactive:'gray', pending:'yellow', published:'green', draft:'gray', banned:'red', true:'green', false:'gray', 1:'green', 0:'gray' };
177
131
  const c = (field.colors && field.colors[String(value)]) || colorMap[String(value)] || 'gray';
178
132
  return `<span class="badge badge-${c}">${value}</span>`;
179
133
  }
180
134
  case 'datetime':
181
- try {
182
- const d = new Date(value);
183
- return `<span title="${d.toISOString()}" style="font-size:12.5px">${d.toLocaleString()}</span>`;
184
- } catch { return String(value); }
135
+ try { const d = new Date(value); return `<span title="${d.toISOString()}" style="font-size:12.5px">${d.toLocaleString()}</span>`; } catch { return String(value); }
185
136
  case 'date':
186
137
  try { return new Date(value).toLocaleDateString(); } catch { return String(value); }
187
- case 'password':
188
- return '<span class="cell-muted" style="letter-spacing:2px">••••••</span>';
189
- case 'image':
190
- return value
191
- ? `<img src="${value}" class="cell-image" alt="">`
192
- : '<span class="cell-muted">—</span>';
193
- case 'json':
194
- return `<code class="cell-mono">${JSON.stringify(value).slice(0, 40)}…</code>`;
195
- case 'email':
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>`;
138
+ case 'password': return '<span class="cell-muted" style="letter-spacing:2px">••••••</span>';
139
+ case 'image': return value ? `<img src="${value}" class="cell-image" alt="">` : '<span class="cell-muted">—</span>';
140
+ case 'json': return `<code class="cell-mono">${JSON.stringify(value).slice(0, 40)}…</code>`;
141
+ case 'email': return `<a href="mailto:${value}" style="color:var(--primary);text-decoration:none">${value}</a>`;
142
+ case 'url': return `<a href="${value}" target="_blank" rel="noopener" style="color:var(--primary);text-decoration:none;word-break:break-all">${value}</a>`;
143
+ case 'phone': return `<a href="tel:${value}" style="color:var(--primary);text-decoration:none">${value}</a>`;
144
+ case 'color': 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>`;
145
+ case 'richtext': 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
146
  case 'fk': {
206
147
  const fkSlug = resolveFkSlug(field.fkResource);
207
148
  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
- }
213
- default: {
214
- const str = String(value);
215
- return str.length > 60
216
- ? `<span title="${str}">${str.slice(0, 60)}…</span>`
217
- : str;
149
+ return fkSlug
150
+ ? `<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>`
151
+ : String(value);
218
152
  }
153
+ default: { const str = String(value); return str.length > 60 ? `<span title="${str}">${str.slice(0, 60)}…</span>` : str; }
219
154
  }
220
155
  });
221
156
 
222
157
  env.addFilter('adminDetail', (value, field) => {
223
- if (value === null || value === undefined || value === '') {
224
- return '<span class="cell-muted">—</span>';
225
- }
158
+ if (value === null || value === undefined || value === '') return '<span class="cell-muted">—</span>';
226
159
  switch (field.type) {
227
- case 'boolean':
228
- return value
229
- ? '<span class="badge badge-green">Yes</span>'
230
- : '<span class="badge badge-gray">No</span>';
160
+ case 'boolean': return value ? '<span class="badge badge-green">Yes</span>' : '<span class="badge badge-gray">No</span>';
231
161
  case 'badge': {
232
162
  const colorMap = { admin:'purple', user:'blue', active:'green', inactive:'gray', pending:'yellow', published:'green', draft:'gray', banned:'red' };
233
163
  const c = (field.colors && field.colors[String(value)]) || colorMap[String(value)] || 'gray';
234
164
  return `<span class="badge badge-${c}">${value}</span>`;
235
165
  }
236
- case 'datetime':
237
- try {
238
- const d = new Date(value);
239
- return `<span title="${d.toISOString()}">${d.toLocaleString()}</span>`;
240
- } catch { return String(value); }
241
- case 'date':
242
- try { return new Date(value).toLocaleDateString(); } catch { return String(value); }
243
- case 'password':
244
- return '<span class="cell-muted" style="letter-spacing:2px">••••••</span>';
245
- case 'image':
246
- return `<img src="${value}" style="width:64px;height:64px;border-radius:8px;object-fit:cover;border:1px solid var(--border)" alt="">`;
247
- case 'url':
248
- return `<a href="${value}" target="_blank" rel="noopener" style="color:var(--primary);word-break:break-all">${value} <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>`;
249
- case 'email':
250
- return `<a href="mailto:${value}" style="color:var(--primary)">${value}</a>`;
251
- case 'color':
252
- return `<span style="display:inline-flex;align-items:center;gap:8px"><span style="width:20px;height:20px;border-radius:4px;background:${value};border:1px solid var(--border);flex-shrink:0"></span><span class="cell-mono">${value}</span></span>`;
253
- case 'json':
254
- try {
255
- const pretty = JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2);
256
- return `<pre style="background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;font-family:'DM Mono',monospace;font-size:12px;overflow-x:auto;white-space:pre-wrap;margin:0;color:var(--text-soft)">${pretty}</pre>`;
257
- } catch { return String(value); }
258
- case 'richtext':
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
- }
166
+ case 'datetime': try { const d = new Date(value); return `<span title="${d.toISOString()}">${d.toLocaleString()}</span>`; } catch { return String(value); }
167
+ case 'date': try { return new Date(value).toLocaleDateString(); } catch { return String(value); }
168
+ case 'password': return '<span class="cell-muted" style="letter-spacing:2px">••••••</span>';
169
+ case 'image': return `<img src="${value}" style="width:64px;height:64px;border-radius:8px;object-fit:cover;border:1px solid var(--border)" alt="">`;
170
+ case 'url': return `<a href="${value}" target="_blank" rel="noopener" style="color:var(--primary);word-break:break-all">${value}</a>`;
171
+ case 'email': return `<a href="mailto:${value}" style="color:var(--primary)">${value}</a>`;
172
+ case 'color': return `<span style="display:inline-flex;align-items:center;gap:8px"><span style="width:20px;height:20px;border-radius:4px;background:${value};border:1px solid var(--border);flex-shrink:0"></span><span class="cell-mono">${value}</span></span>`;
173
+ case 'json': try { const pretty = JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2); return `<pre style="background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;font-size:12px;overflow-x:auto;white-space:pre-wrap;margin:0;color:var(--text-soft)">${pretty}</pre>`; } catch { return String(value); }
174
+ case 'richtext': return `<div style="line-height:1.6;color:var(--text-soft)">${value}</div>`;
175
+ case 'phone': return `<a href="tel:${value}" style="color:var(--primary)">${value}</a>`;
267
176
  case 'fk': {
268
177
  const fkSlug = resolveFkSlug(field.fkResource);
269
178
  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
- }
275
- default: {
276
- const str = String(value);
277
- return str;
179
+ return fkSlug
180
+ ? `<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>`
181
+ : String(value);
278
182
  }
183
+ default: return String(value);
279
184
  }
280
185
  });
281
- env.addFilter('dump', (val) => {
282
- try { return JSON.stringify(val, null, 2); } catch { return String(val); }
283
- });
284
186
 
285
- env.addFilter('min', (arr) => Math.min(...arr));
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
-
294
- env.addFilter('relativeTime', (iso) => {
187
+ env.addFilter('dump', (val) => { try { return JSON.stringify(val, null, 2); } catch { return String(val); } });
188
+ env.addFilter('min', (arr) => Math.min(...arr));
189
+ env.addFilter('tabId', (name) => String(name).replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''));
190
+ env.addFilter('relativeTime', (iso) => {
295
191
  try {
296
192
  const diff = Date.now() - new Date(iso).getTime();
297
193
  const s = Math.floor(diff / 1000);
@@ -305,11 +201,7 @@ class Admin {
305
201
  return env;
306
202
  }
307
203
 
308
- // ─── Base render context ──────────────────────────────────────────────────
309
-
310
204
  _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
205
  let authUserModel = null;
314
206
  try {
315
207
  const container = Facade._container;
@@ -317,11 +209,8 @@ class Admin {
317
209
  const auth = container.make('auth');
318
210
  authUserModel = auth?._UserModel || null;
319
211
  }
320
- } catch { /* container not booted yet or auth not registered */ }
212
+ } catch {}
321
213
 
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
214
  const isAuthResource = (r) => {
326
215
  if (r.authCategory === 'auth') return true;
327
216
  if (authUserModel && r.model && r.model === authUserModel) return true;
@@ -341,10 +230,27 @@ class Admin {
341
230
  label: r._getLabel(),
342
231
  singular: r._getLabelSingular(),
343
232
  icon: r.icon,
233
+ group: r.group || null,
344
234
  canView: r.hasPermission(req.adminUser || null, 'view'),
345
235
  index: idx + 1,
346
236
  category: isAuthResource(r) ? 'auth' : 'app',
347
237
  })),
238
+ navGroups: (() => {
239
+ const appResources = this.resources()
240
+ .filter(r => r.hasPermission(req.adminUser || null, 'view') && !isAuthResource(r));
241
+ const groupMap = new Map();
242
+ for (const r of appResources) {
243
+ const key = r.group || null;
244
+ if (!groupMap.has(key)) groupMap.set(key, []);
245
+ groupMap.get(key).push({ slug: r.slug, label: r._getLabel(), icon: r.icon });
246
+ }
247
+ const groups = [];
248
+ for (const [key, items] of groupMap) {
249
+ if (key !== null) groups.push({ label: key, resources: items });
250
+ }
251
+ if (groupMap.has(null)) groups.push({ label: null, resources: groupMap.get(null) });
252
+ return groups;
253
+ })(),
348
254
  flash: extra._flash || {},
349
255
  activePage: extra.activePage || null,
350
256
  activeResource: extra.activeResource || null,
@@ -356,836 +262,31 @@ class Admin {
356
262
  return this._ctx(req, { ...extra, _flash: AdminAuth.getFlash(req, res) });
357
263
  }
358
264
 
359
- // ─── Auth pages ───────────────────────────────────────────────────────────
360
-
361
- async _loginPage(req, res) {
362
- // Already logged in → redirect to dashboard
363
- if (AdminAuth.enabled && AdminAuth._getSession(req)) {
364
- return res.redirect((req.query.next && decodeURIComponent(req.query.next)) || this._config.prefix + '/');
365
- }
366
-
367
- const flash = AdminAuth.getFlash(req, res);
368
- return this._render(req, res, 'pages/login.njk', {
369
- adminTitle: this._config.title,
370
- adminPrefix: this._config.prefix,
371
- flash,
372
- next: req.query.next || '',
373
- error: null,
374
- });
375
- }
376
-
377
- async _loginSubmit(req, res) {
378
- const { email, password, remember, next } = req.body;
379
- const prefix = this._config.prefix;
380
-
381
- if (!AdminAuth.enabled) {
382
- return res.redirect(next || prefix + '/');
383
- }
384
-
385
- try {
386
- await AdminAuth.login(req, res, {
387
- email,
388
- password,
389
- remember: remember === 'on' || remember === '1' || remember === 'true',
390
- });
391
-
392
- res.redirect(next || prefix + '/');
393
- } catch (err) {
394
- return this._render(req, res, 'pages/login.njk', {
395
- adminTitle: this._config.title,
396
- adminPrefix: prefix,
397
- flash: {},
398
- next: next || '',
399
- error: err.message,
400
- email, // re-fill email field
401
- });
402
- }
403
- }
404
-
405
- _logout(req, res) {
406
- AdminAuth.logout(res);
407
- AdminAuth.setFlash(res, 'success', 'You have been logged out.');
408
- res.redirect(`${this._config.prefix}/login`);
409
- }
410
-
411
- // ─── Pages ────────────────────────────────────────────────────────────────
412
-
413
- async _dashboard(req, res) {
414
- try {
415
- const resourceData = await Promise.all(
416
- this.resources().map(async (R) => {
417
- let count = 0;
418
- let recent = [];
419
- let recentCount = 0;
420
- try {
421
- count = await R.model.count();
422
- const result = await R.fetchList({ page: 1, perPage: 5 });
423
- recent = result.data.map(r => r.toJSON ? r.toJSON() : r);
424
- // Count records created in last 7 days for trend indicator
425
- try {
426
- const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
427
- recentCount = await R.model.where('created_at__gte', since).count();
428
- } catch { /* model may not have created_at */ }
429
- } catch {}
430
- return {
431
- slug: R.slug,
432
- label: R._getLabel(),
433
- singular: R._getLabelSingular(),
434
- icon: R.icon,
435
- count,
436
- recentCount,
437
- recent,
438
- listFields: R.fields()
439
- .filter(f => f._type !== 'tab' && !f._hidden && !f._detailOnly)
440
- .slice(0, 4)
441
- .map(f => f.toJSON()),
442
- };
443
- })
444
- );
445
-
446
- const [activityData, activityTotals] = await Promise.all([
447
- ActivityLog.recent(25),
448
- ActivityLog.totals(),
449
- ]);
450
-
451
- return this._render(req, res, 'pages/dashboard.njk', this._ctxWithFlash(req, res, {
452
- pageTitle: 'Dashboard',
453
- activePage: 'dashboard',
454
- resources: resourceData,
455
- activity: activityData,
456
- activityTotals,
457
- }));
458
- } catch (err) {
459
- this._error(req, res, err);
460
- }
461
- }
462
-
463
- async _list(req, res) {
464
- try {
465
- const R = this._resolve(req.params.resource, res);
466
- if (!R) return;
467
-
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
- };
478
-
479
- const activeFilters = {};
480
- if (req.query.filter) {
481
- for (const [k, v] of Object.entries(req.query.filter)) {
482
- if (v !== '') activeFilters[k] = v;
483
- }
484
- }
485
-
486
- const result = await R.fetchList({ ...query, filters: activeFilters });
487
- const rows = result.data.map(r => r.toJSON ? r.toJSON() : r);
488
-
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);
501
- } catch (err) {
502
- this._error(req, res, err);
503
- }
504
- }
505
-
506
- async _create(req, res) {
507
- try {
508
- const R = this._resolve(req.params.resource, res);
509
- if (!R) return;
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);
517
- } catch (err) {
518
- this._error(req, res, err);
519
- }
520
- }
521
-
522
- async _store(req, res) {
523
- try {
524
- const R = this._resolve(req.params.resource, res);
525
- if (!R) return;
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;
528
-
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);
531
-
532
- const submit = req.body._submit || 'save';
533
- if (submit === 'continue' && record?.id) {
534
- AdminAuth.setFlash(res, 'success', `${R._getLabelSingular()} created. You may continue editing.`);
535
- return res.redirect(`${this._config.prefix}/${R.slug}/${record.id}/edit`);
536
- }
537
- if (submit === 'add_another') {
538
- AdminAuth.setFlash(res, 'success', `${R._getLabelSingular()} created. Add another below.`);
539
- return res.redirect(`${this._config.prefix}/${R.slug}/create`);
540
- }
541
-
542
- this._flash(req, 'success', `${R._getLabelSingular()} created successfully`);
543
- this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
544
- } catch (err) {
545
- if (err.status === 422) {
546
- const R = this._resources.get(req.params.resource);
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);
554
- }
555
- this._error(req, res, err);
556
- }
557
- }
558
-
559
- async _edit(req, res) {
560
- try {
561
- const R = this._resolve(req.params.resource, res);
562
- if (!R) return;
563
- if (!this._perm(R, 'change', req.adminUser)) return res.status(403).send('You do not have permission to change ${R._getLabelSingular()} records.');
564
-
565
- const record = await R.fetchOne(req.params.id);
566
- const data = record.toJSON ? record.toJSON() : record;
567
-
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);
576
- } catch (err) {
577
- this._error(req, res, err);
578
- }
579
- }
580
-
581
- async _update(req, res) {
582
- try {
583
- const R = this._resolve(req.params.resource, res);
584
- if (!R) return;
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;
587
-
588
- // Support method override
589
- const method = req.body._method || 'POST';
590
- if (method === 'PUT' || method === 'POST') {
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);
593
-
594
- const submit = req.body._submit || 'save';
595
- if (submit === 'continue') {
596
- AdminAuth.setFlash(res, 'success', 'Changes saved. You may continue editing.');
597
- return res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}/edit`);
598
- }
599
-
600
- this._flash(req, 'success', `${R._getLabelSingular()} updated successfully`);
601
- this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
602
- }
603
- } catch (err) {
604
- if (err.status === 422) {
605
- const R = this._resources.get(req.params.resource);
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);
615
- }
616
- this._error(req, res, err);
617
- }
618
- }
619
-
620
- async _destroy(req, res) {
621
- try {
622
- const R = this._resolve(req.params.resource, res);
623
- if (!R) return;
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;
626
-
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);
629
- this._flash(req, 'success', `${R._getLabelSingular()} deleted`);
630
- this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
631
- } catch (err) {
632
- this._error(req, res, err);
633
- }
634
- }
635
-
636
- // ─── Detail view (readonly) ───────────────────────────────────────────────
637
-
638
- async _detail(req, res) {
639
- try {
640
- const R = this._resolve(req.params.resource, res);
641
- if (!R) return;
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.');
645
- }
646
-
647
- const record = await R.fetchOne(req.params.id);
648
- const data = record.toJSON ? record.toJSON() : record;
649
-
650
- // Load inline related records
651
- const inlineData = await Promise.all(
652
- (R.inlines || []).map(async (inline, idx) => {
653
- const rows = await inline.fetchRows(data[R.model.primaryKey || 'id']);
654
- return { ...inline.toJSON(), rows, inlineIndex: idx };
655
- })
656
- );
657
-
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);
670
- } catch (err) {
671
- this._error(req, res, err);
672
- }
673
- }
674
-
675
- // ─── Bulk delete ──────────────────────────────────────────────────────────
676
-
677
- async _bulkDestroy(req, res) {
678
- try {
679
- const R = this._resolve(req.params.resource, res);
680
- if (!R) return;
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;
683
-
684
- const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids].filter(Boolean);
685
- if (!ids.length) {
686
- this._flash(req, 'error', 'No records selected.');
687
- return this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
688
- }
689
-
690
- await R.model.destroy(...ids);
691
- ActivityLog.record('delete', R.slug, null, `${ids.length} ${R._getLabel()} (bulk)`, req.adminUser);
692
- this._flash(req, 'success', `Deleted ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
693
- this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
694
- } catch (err) {
695
- this._error(req, res, err);
696
- }
697
- }
698
-
699
- // ─── Bulk custom action ───────────────────────────────────────────────────
700
-
701
- async _bulkAction(req, res) {
702
- try {
703
- const R = this._resolve(req.params.resource, res);
704
- if (!R) return;
705
-
706
- if (!this._verifyCsrf(req, res)) return;
707
- const actionIndex = Number(req.body.actionIndex);
708
- const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids].filter(Boolean);
709
- const action = (R.actions || [])[actionIndex];
710
-
711
- if (!action) {
712
- this._flash(req, 'error', 'Unknown action.');
713
- return this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
714
- }
715
-
716
- if (!ids.length) {
717
- this._flash(req, 'error', 'No records selected.');
718
- return this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
719
- }
720
-
721
- await action.handler(ids, R.model);
722
- ActivityLog.record('update', R.slug, null, `Bulk action "${action.label}" on ${ids.length} records`, req.adminUser);
723
- this._flash(req, 'success', `Action "${action.label}" applied to ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
724
- this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
725
- } catch (err) {
726
- this._error(req, res, err);
727
- }
728
- }
729
-
730
- // ─── Per-row custom action ────────────────────────────────────────────────
731
-
732
- async _rowAction(req, res) {
733
- try {
734
- const R = this._resolve(req.params.resource, res);
735
- if (!R) return;
736
-
737
- if (!this._verifyCsrf(req, res)) return;
738
- const actionName = req.params.action;
739
- const rowAction = (R.rowActions || []).find(a => a.action === actionName);
740
-
741
- if (!rowAction || !rowAction.handler) {
742
- return res.status(404).send(`Row action "${actionName}" not found.`);
743
- }
744
-
745
- const record = await R.fetchOne(req.params.id);
746
- const result = await rowAction.handler(record, R.model);
747
-
748
- // If handler returns a redirect URL, use it; otherwise go back to list
749
- const redirect = (typeof result === 'string' && result.startsWith('/'))
750
- ? result
751
- : `${this._config.prefix}/${R.slug}`;
752
-
753
- ActivityLog.record('update', R.slug, req.params.id, `Action "${rowAction.label}" on #${req.params.id}`, req.adminUser);
754
- this._flash(req, 'success', rowAction.successMessage || `Action "${rowAction.label}" completed.`);
755
- this._redirectWithFlash(res, redirect, req._flashType, req._flashMessage);
756
- } catch (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);
946
- }
947
- }
948
-
949
- async _search(req, res) {
950
- try {
951
- const q = (req.query.q || '').trim();
952
-
953
- if (!q) {
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
- }));
959
- }
960
-
961
- const results = await Promise.all(
962
- this.resources().map(async (R) => {
963
- if (!R.searchable || !R.searchable.length) return null;
964
- try {
965
- const result = await R.fetchList({ page: 1, perPage: 8, search: q });
966
- if (!result.data.length) return null;
967
- return {
968
- slug: R.slug,
969
- label: R._getLabel(),
970
- singular: R._getLabelSingular(),
971
- icon: R.icon,
972
- total: result.total,
973
- rows: result.data.map(r => r.toJSON ? r.toJSON() : r),
974
- listFields: R.fields()
975
- .filter(f => f._type !== 'tab' && !f._hidden && !f._detailOnly)
976
- .slice(0, 4)
977
- .map(f => f.toJSON()),
978
- };
979
- } catch { return null; }
980
- })
981
- );
982
-
983
- const filtered = results.filter(Boolean);
984
- const total = filtered.reduce((s, r) => s + r.total, 0);
985
-
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
- }));
991
- } catch (err) {
992
- this._error(req, res, err);
993
- }
994
- }
995
-
996
- // ─── Export ───────────────────────────────────────────────────────────────
997
-
998
- async _export(req, res) {
999
- try {
1000
- const R = this._resolve(req.params.resource, res);
1001
- if (!R) return;
1002
-
1003
- const format = req.params.format; // 'csv' or 'json'
1004
- const search = req.query.search || '';
1005
- const sort = req.query.sort || 'id';
1006
- const order = req.query.order || 'desc';
1007
-
1008
- const activeFilters = {};
1009
- if (req.query.filter) {
1010
- for (const [k, v] of Object.entries(req.query.filter)) {
1011
- if (v !== '') activeFilters[k] = v;
1012
- }
1013
- }
1014
-
1015
- // Fetch all records (no pagination)
1016
- const result = await R.fetchList({
1017
- page: 1, perPage: 100000, search, sort, order, filters: activeFilters,
1018
- });
1019
- const rows = result.data.map(r => r.toJSON ? r.toJSON() : r);
1020
-
1021
- const fields = R.fields()
1022
- .filter(f => f._type !== 'tab' && !f._hidden)
1023
- .map(f => f.toJSON());
1024
-
1025
- const filename = `${R.slug}-${new Date().toISOString().slice(0, 10)}`;
1026
-
1027
- if (format === 'json') {
1028
- res.setHeader('Content-Type', 'application/json');
1029
- res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
1030
- return res.json(rows);
1031
- }
1032
-
1033
- // CSV
1034
- const header = fields.map(f => `"${f.label}"`).join(',');
1035
- const csvRows = rows.map(row =>
1036
- fields.map(f => {
1037
- const v = row[f.name];
1038
- if (v === null || v === undefined) return '';
1039
- const s = String(typeof v === 'object' ? JSON.stringify(v) : v);
1040
- return `"${s.replace(/"/g, '""')}"`;
1041
- }).join(',')
1042
- );
1043
-
1044
- res.setHeader('Content-Type', 'text/csv');
1045
- res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
1046
- res.send([header, ...csvRows].join('\r\n'));
1047
- } catch (err) {
1048
- this._error(req, res, err);
1049
- }
1050
- }
1051
-
1052
- // ─── Helpers ──────────────────────────────────────────────────────────────
1053
-
1054
- /**
1055
- * Build form fields with tab metadata injected.
1056
- * readonly fields (from readonlyFields[] or field.readonly()) are
1057
- * passed through with a _isReadonly flag so the template can render
1058
- * them as text instead of inputs.
1059
- */
1060
- _formFields(R) {
1061
- const readonlySet = new Set(R.readonlyFields || []);
1062
- const prepopFields = R.prepopulatedFields || {};
1063
- let currentTab = null;
1064
- let currentFieldset = null;
1065
- const result = [];
1066
-
1067
- for (const f of R.fields()) {
1068
- if (f._type === 'tab') {
1069
- currentTab = f._label;
1070
- currentFieldset = null;
1071
- continue;
1072
- }
1073
- if (f._type === 'fieldset') {
1074
- // Include fieldset headers as sentinel objects
1075
- result.push({ _isFieldset: true, label: f._label, tab: currentTab });
1076
- currentFieldset = f._label;
1077
- continue;
1078
- }
1079
- if (f._type === 'id' || f._listOnly || f._hidden) continue;
1080
-
1081
- const json = f.toJSON();
1082
- json.tab = currentTab;
1083
- json.fieldset = currentFieldset;
1084
- json.required = !f._nullable;
1085
- json.prepopulate = prepopFields[f._name] || f._prepopulate || null;
1086
-
1087
- if (readonlySet.has(f._name) || f._readonly) {
1088
- json.isReadonly = true;
1089
- }
1090
-
1091
- result.push(json);
1092
- }
1093
- return result;
1094
- }
1095
-
1096
- /**
1097
- * Build tab structure for tabbed form/detail rendering.
1098
- * Returns [{ label, fields }] — one entry per tab.
1099
- * If no tabs defined, returns a single unnamed tab with all fields.
1100
- */
1101
- _buildTabs(fields) {
1102
- const tabs = [];
1103
- let current = null;
1104
-
1105
- for (const f of fields) {
1106
- if (f._type === 'tab') {
1107
- current = { label: f._label, fields: [] };
1108
- tabs.push(current);
1109
- continue;
1110
- }
1111
- if (f._type === 'fieldset') {
1112
- // Fieldsets are embedded as sentinel entries within a tab's fields
1113
- if (!current) { current = { label: null, fields: [] }; tabs.push(current); }
1114
- current.fields.push({ _isFieldset: true, label: f._label });
1115
- continue;
1116
- }
1117
- if (f._hidden || f._listOnly) continue;
1118
- if (!current) { current = { label: null, fields: [] }; tabs.push(current); }
1119
- current.fields.push(f.toJSON());
1120
- }
1121
-
1122
- return tabs;
1123
- }
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
265
  async _render(req, res, template, ctx, Resource = null) {
1155
266
  const start = Date.now();
1156
-
1157
- // ── before_render ──────────────────────────────────────────────────
1158
267
  let finalCtx = ctx;
1159
268
  try {
1160
269
  const hookCtx = await HookPipeline.run(
1161
270
  'before_render',
1162
271
  { view: template, templateCtx: ctx, user: req.adminUser || null, resource: Resource },
1163
- Resource,
1164
- AdminHooks,
272
+ Resource, AdminHooks,
1165
273
  );
1166
274
  finalCtx = hookCtx.templateCtx || ctx;
1167
275
  } catch (err) {
1168
- // before_render errors abort the render — surface as a 500
1169
276
  return this._error(req, res, err);
1170
277
  }
1171
-
1172
- // ── Render ──────────────────────────────────────────────────────────
1173
278
  res.render(template, finalCtx);
1174
-
1175
- // ── after_render (fire-and-forget) ───────────────────────────────────
1176
279
  setImmediate(() => {
1177
280
  HookPipeline.run(
1178
281
  'after_render',
1179
282
  { 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
- });
283
+ Resource, AdminHooks,
284
+ ).catch(err => process.stderr.write(`[AdminHooks] after_render error: ${err.message}\n`));
1186
285
  });
1187
286
  }
1188
287
 
288
+ _perm(R, action, user) { return R.hasPermission(user || null, action); }
289
+
1189
290
  _verifyCsrf(req, res) {
1190
291
  if (!AdminAuth.enabled) return true;
1191
292
  const token = req.body?._csrf || req.headers['x-csrf-token'];
@@ -1196,47 +297,27 @@ class Admin {
1196
297
 
1197
298
  _resolve(slug, res) {
1198
299
  const R = this._resources.get(slug);
1199
- if (!R) {
1200
- res.status(404).send(`Resource "${slug}" not registered in Admin`);
1201
- return null;
1202
- }
300
+ if (!R) { res.status(404).send(`Resource "${slug}" not registered in Admin`); return null; }
1203
301
  return R;
1204
302
  }
1205
303
 
1206
304
  _error(req, res, err) {
1207
305
  const status = err.status || 500;
1208
306
  const is404 = status === 404;
1209
- const title = is404 ? 'Not found' : `Error ${status}`;
1210
307
  const message = err.message || 'An unexpected error occurred.';
1211
308
  const stack = process.env.NODE_ENV !== 'production' && !is404 ? (err.stack || '') : '';
1212
-
1213
309
  try {
1214
310
  const ctx = this._ctxWithFlash(req, res, {
1215
- pageTitle: title,
1216
- errorStatus: status,
1217
- errorTitle: title,
1218
- errorMsg: message,
1219
- errorStack: stack,
311
+ pageTitle: is404 ? 'Not found' : `Error ${status}`,
312
+ errorStatus: status, errorTitle: is404 ? 'Not found' : `Error ${status}`,
313
+ errorMsg: message, errorStack: stack,
1220
314
  });
1221
315
  res.status(status);
1222
316
  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
- }
317
+ } catch { res.status(status).send(`<pre>${message}</pre>`); }
1227
318
  }
1228
319
 
1229
- // ─── Flash (cookie-based) ─────────────────────────────────────────────────
1230
-
1231
- _flash(req, type, message) {
1232
- req._flashType = type;
1233
- req._flashMessage = message;
1234
- }
1235
-
1236
- _pullFlash(req) {
1237
- if (req._flashType) return { [req._flashType]: req._flashMessage };
1238
- return {};
1239
- }
320
+ _flash(req, type, message) { req._flashType = type; req._flashMessage = message; }
1240
321
 
1241
322
  _redirectWithFlash(res, url, type, message) {
1242
323
  if (type && message) AdminAuth.setFlash(res, type, message);
@@ -1245,14 +326,13 @@ class Admin {
1245
326
 
1246
327
  _autoResource(ModelClass) {
1247
328
  const R = class extends AdminResource {};
1248
- R.model = ModelClass;
1249
- R.label = ModelClass.name + 's';
329
+ R.model = ModelClass;
330
+ R.label = ModelClass.name + 's';
1250
331
  R.labelSingular = ModelClass.name;
1251
332
  return R;
1252
333
  }
1253
334
  }
1254
335
 
1255
- // Singleton
1256
336
  const admin = new Admin();
1257
337
  module.exports = admin;
1258
338
  module.exports.Admin = Admin;