millas 0.2.13 → 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.
- package/package.json +6 -3
- package/src/admin/Admin.js +107 -1027
- package/src/admin/AdminAuth.js +1 -1
- package/src/admin/ViewContext.js +1 -1
- package/src/admin/handlers/ActionHandler.js +103 -0
- package/src/admin/handlers/ApiHandler.js +113 -0
- package/src/admin/handlers/AuthHandler.js +76 -0
- package/src/admin/handlers/ExportHandler.js +70 -0
- package/src/admin/handlers/InlineHandler.js +71 -0
- package/src/admin/handlers/PageHandler.js +351 -0
- package/src/admin/resources/AdminResource.js +22 -1
- package/src/admin/static/SelectFilter2.js +34 -0
- package/src/admin/static/actions.js +201 -0
- package/src/admin/static/admin.css +7 -0
- package/src/admin/static/change_form.js +585 -0
- package/src/admin/static/core.js +128 -0
- package/src/admin/static/login.js +76 -0
- package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
- package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
- package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
- package/src/admin/static/vendor/jquery.min.js +2 -0
- package/src/admin/views/layouts/base.njk +30 -113
- package/src/admin/views/pages/detail.njk +10 -9
- package/src/admin/views/pages/form.njk +4 -4
- package/src/admin/views/pages/list.njk +11 -193
- package/src/admin/views/pages/login.njk +19 -64
- package/src/admin/views/partials/form-field.njk +1 -1
- package/src/admin/views/partials/form-scripts.njk +4 -478
- package/src/admin/views/partials/form-widget.njk +10 -10
- package/src/ai/AITokenBudget.js +1 -1
- package/src/auth/Auth.js +112 -3
- package/src/auth/AuthMiddleware.js +18 -15
- package/src/auth/Hasher.js +15 -43
- package/src/cli.js +3 -0
- package/src/commands/call.js +190 -0
- package/src/commands/createsuperuser.js +3 -4
- package/src/commands/key.js +97 -0
- package/src/commands/make.js +16 -2
- package/src/commands/new.js +16 -1
- package/src/commands/serve.js +5 -5
- package/src/console/Command.js +337 -0
- package/src/console/CommandLoader.js +165 -0
- package/src/console/index.js +6 -0
- package/src/container/AppInitializer.js +48 -1
- package/src/container/Application.js +3 -1
- package/src/container/HttpServer.js +0 -1
- package/src/container/MillasConfig.js +48 -0
- package/src/controller/Controller.js +13 -11
- package/src/core/docs.js +6 -0
- package/src/core/foundation.js +8 -0
- package/src/core/http.js +20 -10
- package/src/core/validation.js +58 -27
- package/src/docs/Docs.js +268 -0
- package/src/docs/DocsServiceProvider.js +80 -0
- package/src/docs/SchemaInferrer.js +131 -0
- package/src/docs/handlers/ApiHandler.js +305 -0
- package/src/docs/handlers/PageHandler.js +47 -0
- package/src/docs/index.js +13 -0
- package/src/docs/resources/ApiResource.js +402 -0
- package/src/docs/static/docs.css +723 -0
- package/src/docs/static/docs.js +1181 -0
- package/src/encryption/Encrypter.js +381 -0
- package/src/facades/Auth.js +5 -2
- package/src/facades/Crypt.js +166 -0
- package/src/facades/Docs.js +43 -0
- package/src/facades/Mail.js +1 -1
- package/src/http/MillasRequest.js +7 -31
- package/src/http/RequestContext.js +11 -7
- package/src/http/SecurityBootstrap.js +24 -2
- package/src/http/Shape.js +168 -0
- package/src/http/adapters/ExpressAdapter.js +9 -5
- package/src/middleware/CorsMiddleware.js +3 -0
- package/src/middleware/ThrottleMiddleware.js +10 -7
- package/src/orm/model/Model.js +14 -1
- package/src/providers/EncryptionServiceProvider.js +66 -0
- package/src/router/MiddlewareRegistry.js +79 -54
- package/src/router/Route.js +9 -4
- package/src/router/RouteEntry.js +91 -0
- package/src/router/Router.js +71 -1
- package/src/scaffold/maker.js +138 -1
- package/src/scaffold/templates.js +12 -0
- package/src/serializer/Serializer.js +239 -0
- package/src/support/Str.js +1080 -0
- package/src/validation/BaseValidator.js +45 -5
- package/src/validation/Validator.js +67 -61
- package/src/validation/types.js +490 -0
- package/src/middleware/AuthMiddleware.js +0 -46
- package/src/middleware/MiddlewareRegistry.js +0 -106
package/src/admin/Admin.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
expressApp.get(`${prefix}
|
|
110
|
-
expressApp.get(`${prefix}
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
expressApp.get (`${prefix}/:resource`,
|
|
117
|
-
expressApp.get (`${prefix}/:resource/
|
|
118
|
-
expressApp.
|
|
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
|
-
// ──
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
expressApp.post(`${prefix}/:resource/:id/
|
|
137
|
-
|
|
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
|
-
|
|
189
|
-
case '
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
case '
|
|
194
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
case '
|
|
242
|
-
|
|
243
|
-
case '
|
|
244
|
-
|
|
245
|
-
case '
|
|
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
|
-
|
|
271
|
-
|
|
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('
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
1216
|
-
errorStatus: status,
|
|
1217
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
1249
|
-
R.label
|
|
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;
|