millas 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +6 -3
  2. package/src/admin/Admin.js +107 -1027
  3. package/src/admin/AdminAuth.js +1 -1
  4. package/src/admin/ViewContext.js +1 -1
  5. package/src/admin/handlers/ActionHandler.js +103 -0
  6. package/src/admin/handlers/ApiHandler.js +113 -0
  7. package/src/admin/handlers/AuthHandler.js +76 -0
  8. package/src/admin/handlers/ExportHandler.js +70 -0
  9. package/src/admin/handlers/InlineHandler.js +71 -0
  10. package/src/admin/handlers/PageHandler.js +351 -0
  11. package/src/admin/resources/AdminResource.js +22 -1
  12. package/src/admin/static/SelectFilter2.js +34 -0
  13. package/src/admin/static/actions.js +201 -0
  14. package/src/admin/static/admin.css +7 -0
  15. package/src/admin/static/change_form.js +585 -0
  16. package/src/admin/static/core.js +128 -0
  17. package/src/admin/static/login.js +76 -0
  18. package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
  19. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
  20. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
  21. package/src/admin/static/vendor/jquery.min.js +2 -0
  22. package/src/admin/views/layouts/base.njk +30 -113
  23. package/src/admin/views/pages/detail.njk +10 -9
  24. package/src/admin/views/pages/form.njk +4 -4
  25. package/src/admin/views/pages/list.njk +11 -193
  26. package/src/admin/views/pages/login.njk +19 -64
  27. package/src/admin/views/partials/form-field.njk +1 -1
  28. package/src/admin/views/partials/form-scripts.njk +4 -478
  29. package/src/admin/views/partials/form-widget.njk +10 -10
  30. package/src/ai/AITokenBudget.js +1 -1
  31. package/src/auth/Auth.js +112 -3
  32. package/src/auth/AuthMiddleware.js +18 -15
  33. package/src/auth/Hasher.js +15 -43
  34. package/src/cli.js +3 -0
  35. package/src/commands/call.js +190 -0
  36. package/src/commands/createsuperuser.js +3 -4
  37. package/src/commands/key.js +97 -0
  38. package/src/commands/make.js +16 -2
  39. package/src/commands/new.js +16 -1
  40. package/src/commands/serve.js +5 -5
  41. package/src/console/Command.js +337 -0
  42. package/src/console/CommandLoader.js +165 -0
  43. package/src/console/index.js +6 -0
  44. package/src/container/AppInitializer.js +48 -1
  45. package/src/container/Application.js +3 -1
  46. package/src/container/HttpServer.js +0 -1
  47. package/src/container/MillasConfig.js +48 -0
  48. package/src/controller/Controller.js +13 -11
  49. package/src/core/docs.js +6 -0
  50. package/src/core/foundation.js +8 -0
  51. package/src/core/http.js +20 -10
  52. package/src/core/validation.js +58 -27
  53. package/src/docs/Docs.js +268 -0
  54. package/src/docs/DocsServiceProvider.js +80 -0
  55. package/src/docs/SchemaInferrer.js +131 -0
  56. package/src/docs/handlers/ApiHandler.js +305 -0
  57. package/src/docs/handlers/PageHandler.js +47 -0
  58. package/src/docs/index.js +13 -0
  59. package/src/docs/resources/ApiResource.js +402 -0
  60. package/src/docs/static/docs.css +723 -0
  61. package/src/docs/static/docs.js +1181 -0
  62. package/src/encryption/Encrypter.js +381 -0
  63. package/src/facades/Auth.js +5 -2
  64. package/src/facades/Crypt.js +166 -0
  65. package/src/facades/Docs.js +43 -0
  66. package/src/facades/Mail.js +1 -1
  67. package/src/http/MillasRequest.js +7 -31
  68. package/src/http/RequestContext.js +11 -7
  69. package/src/http/SecurityBootstrap.js +24 -2
  70. package/src/http/Shape.js +168 -0
  71. package/src/http/adapters/ExpressAdapter.js +9 -5
  72. package/src/middleware/CorsMiddleware.js +3 -0
  73. package/src/middleware/ThrottleMiddleware.js +10 -7
  74. package/src/orm/model/Model.js +20 -2
  75. package/src/providers/EncryptionServiceProvider.js +66 -0
  76. package/src/router/MiddlewareRegistry.js +79 -54
  77. package/src/router/Route.js +9 -4
  78. package/src/router/RouteEntry.js +91 -0
  79. package/src/router/Router.js +71 -1
  80. package/src/scaffold/maker.js +138 -1
  81. package/src/scaffold/templates.js +12 -0
  82. package/src/serializer/Serializer.js +239 -0
  83. package/src/support/Str.js +1080 -0
  84. package/src/validation/BaseValidator.js +45 -5
  85. package/src/validation/Validator.js +67 -61
  86. package/src/validation/types.js +490 -0
  87. package/src/middleware/AuthMiddleware.js +0 -46
  88. package/src/middleware/MiddlewareRegistry.js +0 -106
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
+ const Hasher = require('../auth/Hasher');
4
5
 
5
6
  /**
6
7
  * AdminAuth
@@ -166,7 +167,6 @@ class AdminAuth {
166
167
  const user = await this._loadUserByEmail(normalised);
167
168
 
168
169
  // Always check password first — avoids leaking account existence
169
- const Hasher = require('../auth/Hasher');
170
170
  const validPassword = user ? await Hasher.check(password, user.password) : false;
171
171
 
172
172
  if (!user || !validPassword) {
@@ -60,7 +60,7 @@ class ViewContext {
60
60
  static base({ admin, user, csrfToken, flash = {}, activePage = null, activeResource = null }) {
61
61
  return {
62
62
  csrfToken,
63
- adminPrefix: admin._config.prefix,
63
+ adminPrefix: admin._config.prefix.replace(/\/+$/, ''),
64
64
  adminTitle: admin._config.title,
65
65
  adminUser: user || null,
66
66
  authEnabled: admin._auth?.enabled ?? false,
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ const ActivityLog = require('../ActivityLog');
4
+
5
+ /**
6
+ * ActionHandler
7
+ *
8
+ * Handles bulk and per-row custom actions:
9
+ * POST /admin/:resource/bulk-delete
10
+ * POST /admin/:resource/bulk-action
11
+ * POST /admin/:resource/:id/action/:action
12
+ */
13
+ class ActionHandler {
14
+ constructor(admin) {
15
+ this._admin = admin;
16
+ }
17
+
18
+ async bulkDestroy(req, res) {
19
+ const admin = this._admin;
20
+ try {
21
+ const R = admin._resolve(req.params.resource, res);
22
+ if (!R) return;
23
+ if (!admin._perm(R, 'delete', req.adminUser)) {
24
+ return res.status(403).send(`You do not have permission to delete ${R._getLabelSingular()} records.`);
25
+ }
26
+ if (!admin._verifyCsrf(req, res)) return;
27
+
28
+ const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids].filter(Boolean);
29
+ if (!ids.length) {
30
+ admin._flash(req, 'error', 'No records selected.');
31
+ return admin._redirectWithFlash(res, `${admin._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
32
+ }
33
+
34
+ await R.model.destroy(...ids);
35
+ ActivityLog.record('delete', R.slug, null, `${ids.length} ${R._getLabel()} (bulk)`, req.adminUser);
36
+ admin._flash(req, 'success', `Deleted ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
37
+ admin._redirectWithFlash(res, `${admin._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
38
+ } catch (err) {
39
+ admin._error(req, res, err);
40
+ }
41
+ }
42
+
43
+ async bulkAction(req, res) {
44
+ const admin = this._admin;
45
+ try {
46
+ const R = admin._resolve(req.params.resource, res);
47
+ if (!R) return;
48
+ if (!admin._verifyCsrf(req, res)) return;
49
+
50
+ const actionIndex = Number(req.body.actionIndex);
51
+ const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids].filter(Boolean);
52
+ const action = (R.actions || [])[actionIndex];
53
+
54
+ if (!action) {
55
+ admin._flash(req, 'error', 'Unknown action.');
56
+ return admin._redirectWithFlash(res, `${admin._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
57
+ }
58
+
59
+ if (!ids.length) {
60
+ admin._flash(req, 'error', 'No records selected.');
61
+ return admin._redirectWithFlash(res, `${admin._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
62
+ }
63
+
64
+ await action.handler(ids, R.model);
65
+ ActivityLog.record('update', R.slug, null, `Bulk action "${action.label}" on ${ids.length} records`, req.adminUser);
66
+ admin._flash(req, 'success', `Action "${action.label}" applied to ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
67
+ admin._redirectWithFlash(res, `${admin._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
68
+ } catch (err) {
69
+ admin._error(req, res, err);
70
+ }
71
+ }
72
+
73
+ async rowAction(req, res) {
74
+ const admin = this._admin;
75
+ try {
76
+ const R = admin._resolve(req.params.resource, res);
77
+ if (!R) return;
78
+ if (!admin._verifyCsrf(req, res)) return;
79
+
80
+ const actionName = req.params.action;
81
+ const rowAction = (R.rowActions || []).find(a => a.action === actionName);
82
+
83
+ if (!rowAction || !rowAction.handler) {
84
+ return res.status(404).send(`Row action "${actionName}" not found.`);
85
+ }
86
+
87
+ const record = await R.fetchOne(req.params.id);
88
+ const result = await rowAction.handler(record, R.model);
89
+
90
+ const redirect = (typeof result === 'string' && result.startsWith('/'))
91
+ ? result
92
+ : `${admin._config.prefix}/${R.slug}`;
93
+
94
+ ActivityLog.record('update', R.slug, req.params.id, `Action "${rowAction.label}" on #${req.params.id}`, req.adminUser);
95
+ admin._flash(req, 'success', rowAction.successMessage || `Action "${rowAction.label}" completed.`);
96
+ admin._redirectWithFlash(res, redirect, req._flashType, req._flashMessage);
97
+ } catch (err) {
98
+ admin._error(req, res, err);
99
+ }
100
+ }
101
+ }
102
+
103
+ module.exports = ActionHandler;
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ const LookupParser = require('../../orm/query/LookupParser');
4
+
5
+ /**
6
+ * ApiHandler
7
+ *
8
+ * Handles the internal JSON API used by FK and M2M widgets:
9
+ * GET /admin/api/:resource/options?q=&page=&limit=&field=&from=
10
+ *
11
+ * Returns paginated { id, label } pairs for autocomplete selects.
12
+ */
13
+ class ApiHandler {
14
+ constructor(admin) {
15
+ this._admin = admin;
16
+ }
17
+
18
+ async options(req, res) {
19
+ const admin = this._admin;
20
+ try {
21
+ const slug = req.params.resource;
22
+ let R = admin._resources.get(slug);
23
+ if (!R) {
24
+ for (const resource of admin._resources.values()) {
25
+ if (resource.model && resource.model.table === slug) { R = resource; break; }
26
+ }
27
+ }
28
+ if (!R) return res.status(404).json({ error: `Resource "${slug}" not found` });
29
+ if (!R.hasPermission(req.adminUser || null, 'view')) {
30
+ return res.status(403).json({ error: 'Forbidden' });
31
+ }
32
+
33
+ const search = (req.query.q || '').trim();
34
+ const page = Math.max(1, Number(req.query.page) || 1);
35
+ const perPage = Math.min(Number(req.query.limit) || 20, 100);
36
+ const offset = (page - 1) * perPage;
37
+ const pk = R.model.primaryKey || 'id';
38
+
39
+ // Label column resolution — priority:
40
+ // 1. resource.fkLabel (developer override)
41
+ // 2. resource.searchable[0]
42
+ // 3. Auto-detect: name > email > title > label > first string field
43
+ // 4. pk fallback
44
+ let labelCol = R.fkLabel || (R.searchable && R.searchable[0]) || null;
45
+ if (!labelCol && R.model) {
46
+ const fields = typeof R.model.getFields === 'function'
47
+ ? R.model.getFields()
48
+ : (R.model.fields || {});
49
+ const preferred = ['name', 'email', 'title', 'label', 'full_name',
50
+ 'fullname', 'username', 'display_name', 'first_name'];
51
+ for (const p of preferred) {
52
+ if (fields[p]) { labelCol = p; break; }
53
+ }
54
+ if (!labelCol) {
55
+ const skip = new Set(['password', 'token', 'secret', 'hash', 'remember_token']);
56
+ for (const [col, def] of Object.entries(fields)) {
57
+ if (def.type === 'string' && !skip.has(col) && col !== pk) {
58
+ labelCol = col; break;
59
+ }
60
+ }
61
+ }
62
+ }
63
+ labelCol = labelCol || pk;
64
+
65
+ // Resolve fkWhere from the source resource's field definition
66
+ const fieldName = (req.query.field || '').trim();
67
+ const fromSlug = (req.query.from || '').trim();
68
+ let fkWhere = null;
69
+ if (fieldName && fromSlug) {
70
+ const sourceResource = admin._resources.get(fromSlug)
71
+ || [...admin._resources.values()].find(r => r.model?.table === fromSlug);
72
+ if (sourceResource) {
73
+ const fieldDef = (sourceResource.fields() || []).find(f => f._name === fieldName);
74
+ if (fieldDef && fieldDef._fkWhere) fkWhere = fieldDef._fkWhere;
75
+ }
76
+ }
77
+
78
+ const applyScope = (q) => {
79
+ if (!fkWhere) return q;
80
+ if (typeof fkWhere === 'function') return fkWhere(q) || q;
81
+ for (const [key, value] of Object.entries(fkWhere)) {
82
+ LookupParser.apply(q, key, value, R.model);
83
+ }
84
+ return q;
85
+ };
86
+
87
+ let countQ = applyScope(R.model._db().count(`${pk} as total`));
88
+ if (search) countQ = countQ.where(labelCol, 'like', `%${search}%`);
89
+ const [{ total }] = await countQ;
90
+
91
+ let rowQ = applyScope(R.model._db()
92
+ .select([`${pk} as id`, `${labelCol} as label`])
93
+ .orderBy(labelCol, 'asc')
94
+ .limit(perPage)
95
+ .offset(offset));
96
+ if (search) rowQ = rowQ.where(labelCol, 'like', `%${search}%`);
97
+ const rows = await rowQ;
98
+
99
+ return res.json({
100
+ data: rows,
101
+ total: Number(total),
102
+ page,
103
+ perPage,
104
+ hasMore: offset + rows.length < Number(total),
105
+ labelCol,
106
+ });
107
+ } catch (err) {
108
+ return res.status(500).json({ error: err.message });
109
+ }
110
+ }
111
+ }
112
+
113
+ module.exports = ApiHandler;
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ const AdminAuth = require('../AdminAuth');
4
+
5
+ /**
6
+ * AuthHandler
7
+ *
8
+ * Handles the three admin authentication routes:
9
+ * GET /admin/login
10
+ * POST /admin/login
11
+ * GET /admin/logout
12
+ */
13
+ class AuthHandler {
14
+ constructor(admin) {
15
+ this._admin = admin;
16
+ }
17
+
18
+ async loginPage(req, res) {
19
+ const admin = this._admin;
20
+
21
+ // Already logged in → redirect to dashboard
22
+ if (AdminAuth.enabled && AdminAuth._getSession(req)) {
23
+ return res.redirect(
24
+ (req.query.next && decodeURIComponent(req.query.next)) ||
25
+ admin._config.prefix + '/'
26
+ );
27
+ }
28
+
29
+ const flash = AdminAuth.getFlash(req, res);
30
+ return admin._render(req, res, 'pages/login.njk', {
31
+ adminTitle: admin._config.title,
32
+ adminPrefix: admin._config.prefix,
33
+ flash,
34
+ next: req.query.next || '',
35
+ error: null,
36
+ });
37
+ }
38
+
39
+ async loginSubmit(req, res) {
40
+ const admin = this._admin;
41
+ const { email, password, remember, next } = req.body;
42
+ const prefix = admin._config.prefix;
43
+
44
+ if (!AdminAuth.enabled) {
45
+ return res.redirect(next || prefix + '/');
46
+ }
47
+
48
+ try {
49
+ await AdminAuth.login(req, res, {
50
+ email,
51
+ password,
52
+ remember: remember === 'on' || remember === '1' || remember === 'true',
53
+ });
54
+
55
+ res.redirect(next || prefix + '/');
56
+ } catch (err) {
57
+ return admin._render(req, res, 'pages/login.njk', {
58
+ adminTitle: admin._config.title,
59
+ adminPrefix: prefix,
60
+ flash: {},
61
+ next: next || '',
62
+ error: err.message,
63
+ email,
64
+ });
65
+ }
66
+ }
67
+
68
+ logout(req, res) {
69
+ const admin = this._admin;
70
+ AdminAuth.logout(res);
71
+ AdminAuth.setFlash(res, 'success', 'You have been logged out.');
72
+ res.redirect(`${admin._config.prefix}/login`);
73
+ }
74
+ }
75
+
76
+ module.exports = AuthHandler;
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ExportHandler
5
+ *
6
+ * Handles resource data exports:
7
+ * GET /admin/:resource/export.csv
8
+ * GET /admin/:resource/export.json
9
+ */
10
+ class ExportHandler {
11
+ constructor(admin) {
12
+ this._admin = admin;
13
+ }
14
+
15
+ async export(req, res) {
16
+ const admin = this._admin;
17
+ try {
18
+ const R = admin._resolve(req.params.resource, res);
19
+ if (!R) return;
20
+
21
+ const format = req.params.format;
22
+ const search = req.query.search || '';
23
+ const sort = req.query.sort || 'id';
24
+ const order = req.query.order || 'desc';
25
+
26
+ const activeFilters = {};
27
+ if (req.query.filter) {
28
+ for (const [k, v] of Object.entries(req.query.filter)) {
29
+ if (v !== '') activeFilters[k] = v;
30
+ }
31
+ }
32
+
33
+ const result = await R.fetchList({
34
+ page: 1, perPage: 100000, search, sort, order, filters: activeFilters,
35
+ });
36
+ const rows = result.data.map(r => r.toJSON ? r.toJSON() : r);
37
+
38
+ const fields = R.fields()
39
+ .filter(f => f._type !== 'tab' && !f._hidden)
40
+ .map(f => f.toJSON());
41
+
42
+ const filename = `${R.slug}-${new Date().toISOString().slice(0, 10)}`;
43
+
44
+ if (format === 'json') {
45
+ res.setHeader('Content-Type', 'application/json');
46
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
47
+ return res.json(rows);
48
+ }
49
+
50
+ // CSV
51
+ const header = fields.map(f => `"${f.label}"`).join(',');
52
+ const csvRows = rows.map(row =>
53
+ fields.map(f => {
54
+ const v = row[f.name];
55
+ if (v === null || v === undefined) return '';
56
+ const s = String(typeof v === 'object' ? JSON.stringify(v) : v);
57
+ return `"${s.replace(/"/g, '""')}"`;
58
+ }).join(',')
59
+ );
60
+
61
+ res.setHeader('Content-Type', 'text/csv');
62
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
63
+ res.send([header, ...csvRows].join('\r\n'));
64
+ } catch (err) {
65
+ admin._error(req, res, err);
66
+ }
67
+ }
68
+ }
69
+
70
+ module.exports = ExportHandler;
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const ActivityLog = require('../ActivityLog');
4
+ const AdminAuth = require('../AdminAuth');
5
+
6
+ /**
7
+ * InlineHandler
8
+ *
9
+ * Handles inline related-record CRUD on the detail page:
10
+ * POST /admin/:resource/:id/inline/:inlineIndex
11
+ * POST /admin/:resource/:id/inline/:inlineIndex/:rowId/delete
12
+ */
13
+ class InlineHandler {
14
+ constructor(admin) {
15
+ this._admin = admin;
16
+ }
17
+
18
+ async store(req, res) {
19
+ const admin = this._admin;
20
+ try {
21
+ const R = admin._resolve(req.params.resource, res);
22
+ if (!R) return;
23
+ if (!admin._verifyCsrf(req, res)) return;
24
+
25
+ const idx = Number(req.params.inlineIndex);
26
+ const inline = (R.inlines || [])[idx];
27
+ if (!inline) return res.status(404).send('Inline not found.');
28
+ if (!inline.canCreate) return res.status(403).send('Inline create is disabled.');
29
+
30
+ const data = {
31
+ ...req.body,
32
+ [inline.foreignKey]: req.params.id,
33
+ };
34
+ delete data._csrf;
35
+ delete data._method;
36
+ delete data._submit;
37
+
38
+ await inline.model.create(data);
39
+ ActivityLog.record('create', inline.label, null, `Inline ${inline.label} for #${req.params.id}`, req.adminUser);
40
+
41
+ AdminAuth.setFlash(res, 'success', `${inline.label} added.`);
42
+ res.redirect(`${admin._config.prefix}/${R.slug}/${req.params.id}`);
43
+ } catch (err) {
44
+ admin._error(req, res, err);
45
+ }
46
+ }
47
+
48
+ async destroy(req, res) {
49
+ const admin = this._admin;
50
+ try {
51
+ const R = admin._resolve(req.params.resource, res);
52
+ if (!R) return;
53
+ if (!admin._verifyCsrf(req, res)) return;
54
+
55
+ const idx = Number(req.params.inlineIndex);
56
+ const inline = (R.inlines || [])[idx];
57
+ if (!inline) return res.status(404).send('Inline not found.');
58
+ if (!inline.canDelete) return res.status(403).send('Inline delete is disabled.');
59
+
60
+ await inline.model.destroy(req.params.rowId);
61
+ ActivityLog.record('delete', inline.label, req.params.rowId, `Inline ${inline.label} #${req.params.rowId}`, req.adminUser);
62
+
63
+ AdminAuth.setFlash(res, 'success', 'Record deleted.');
64
+ res.redirect(`${admin._config.prefix}/${R.slug}/${req.params.id}`);
65
+ } catch (err) {
66
+ admin._error(req, res, err);
67
+ }
68
+ }
69
+ }
70
+
71
+ module.exports = InlineHandler;