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.
- package/package.json +3 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +412 -344
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- package/src/facades/Validation.js +0 -69
package/src/admin/Admin.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const nunjucks = require('nunjucks');
|
|
5
|
-
const 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()
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
348
|
-
|
|
396
|
+
const [activityData, activityTotals] = await Promise.all([
|
|
397
|
+
ActivityLog.recent(25),
|
|
398
|
+
ActivityLog.totals(),
|
|
399
|
+
]);
|
|
349
400
|
|
|
350
|
-
|
|
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
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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({
|
|
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
|
|
387
|
-
.
|
|
388
|
-
.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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.
|
|
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
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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.
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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.
|
|
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
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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.
|
|
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.
|
|
573
|
-
if (R.
|
|
574
|
-
return res.status(403).send('
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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.
|
|
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
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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;
|