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