millas 0.2.4 → 0.2.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "millas",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "A modern batteries-included backend framework for Node.js — built on Express, inspired by Laravel, Django, and FastAPI",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -3,7 +3,8 @@
3
3
  const path = require('path');
4
4
  const nunjucks = require('nunjucks');
5
5
  const ActivityLog = require('./ActivityLog');
6
- const { AdminResource, AdminField, AdminFilter } = require('./resources/AdminResource');
6
+ const AdminAuth = require('./AdminAuth');
7
+ const { AdminResource, AdminField, AdminFilter, AdminInline } = require('./resources/AdminResource');
7
8
 
8
9
  /**
9
10
  * Admin
@@ -36,6 +37,12 @@ class Admin {
36
37
 
37
38
  configure(config = {}) {
38
39
  Object.assign(this._config, config);
40
+
41
+ // Initialise auth if configured
42
+ if (config.auth !== undefined) {
43
+ AdminAuth.configure(config.auth);
44
+ }
45
+
39
46
  return this;
40
47
  }
41
48
 
@@ -72,6 +79,14 @@ class Admin {
72
79
  const prefix = this._config.prefix;
73
80
  this._njk = this._setupNunjucks(expressApp);
74
81
 
82
+ // ── Auth middleware (runs before all admin routes) ──────────
83
+ expressApp.use(prefix, AdminAuth.middleware(prefix));
84
+
85
+ // ── Login / logout ──────────────────────────────────────────
86
+ expressApp.get (`${prefix}/login`, (q, s) => this._loginPage(q, s));
87
+ expressApp.post(`${prefix}/login`, (q, s) => this._loginSubmit(q, s));
88
+ expressApp.get (`${prefix}/logout`, (q, s) => this._logout(q, s));
89
+
75
90
  // Dashboard
76
91
  expressApp.get(`${prefix}`, (q, s) => this._dashboard(q, s));
77
92
  expressApp.get(`${prefix}/`, (q, s) => this._dashboard(q, s));
@@ -89,6 +104,8 @@ class Admin {
89
104
  expressApp.post (`${prefix}/:resource/:id`, (q, s) => this._update(q, s));
90
105
  expressApp.post (`${prefix}/:resource/:id/delete`, (q, s) => this._destroy(q, s));
91
106
  expressApp.post (`${prefix}/:resource/bulk-delete`, (q, s) => this._bulkDestroy(q, s));
107
+ expressApp.post (`${prefix}/:resource/bulk-action`, (q, s) => this._bulkAction(q, s));
108
+ expressApp.post (`${prefix}/:resource/:id/action/:action`,(q, s) => this._rowAction(q, s));
92
109
 
93
110
  return this;
94
111
  }
@@ -216,6 +233,8 @@ class Admin {
216
233
  return {
217
234
  adminPrefix: this._config.prefix,
218
235
  adminTitle: this._config.title,
236
+ adminUser: req.adminUser || null,
237
+ authEnabled: AdminAuth.enabled,
219
238
  resources: this.resources().map((r, idx) => ({
220
239
  slug: r.slug,
221
240
  label: r._getLabel(),
@@ -224,13 +243,72 @@ class Admin {
224
243
  canView: r.canView,
225
244
  index: idx + 1,
226
245
  })),
227
- flash: this._pullFlash(req),
246
+ flash: extra._flash || {},
228
247
  activePage: extra.activePage || null,
229
248
  activeResource: extra.activeResource || null,
230
249
  ...extra,
231
250
  };
232
251
  }
233
252
 
253
+ _ctxWithFlash(req, res, extra = {}) {
254
+ return this._ctx(req, { ...extra, _flash: AdminAuth.getFlash(req, res) });
255
+ }
256
+
257
+ // ─── Auth pages ───────────────────────────────────────────────────────────
258
+
259
+ async _loginPage(req, res) {
260
+ // Already logged in → redirect to dashboard
261
+ if (AdminAuth.enabled) {
262
+ const cookies = req.headers.cookie || '';
263
+ if (cookies.includes(this._config.auth?.cookieName || 'millas_admin')) {
264
+ // Let AdminAuth verify properly
265
+ }
266
+ }
267
+
268
+ const flash = AdminAuth.getFlash(req, res);
269
+ res.render('pages/login.njk', {
270
+ adminTitle: this._config.title,
271
+ adminPrefix: this._config.prefix,
272
+ flash,
273
+ next: req.query.next || '',
274
+ error: null,
275
+ });
276
+ }
277
+
278
+ async _loginSubmit(req, res) {
279
+ const { email, password, remember, next } = req.body;
280
+ const prefix = this._config.prefix;
281
+
282
+ if (!AdminAuth.enabled) {
283
+ return res.redirect(next || prefix + '/');
284
+ }
285
+
286
+ try {
287
+ await AdminAuth.login(req, res, {
288
+ email,
289
+ password,
290
+ remember: remember === 'on' || remember === '1' || remember === 'true',
291
+ });
292
+
293
+ res.redirect(next || prefix + '/');
294
+ } catch (err) {
295
+ res.render('pages/login.njk', {
296
+ adminTitle: this._config.title,
297
+ adminPrefix: prefix,
298
+ flash: {},
299
+ next: next || '',
300
+ error: err.message,
301
+ email, // re-fill email field
302
+ });
303
+ }
304
+ }
305
+
306
+ _logout(req, res) {
307
+ AdminAuth.logout(res);
308
+ AdminAuth.setFlash(res, 'success', 'You have been logged out.');
309
+ res.redirect(`${this._config.prefix}/login`);
310
+ }
311
+
234
312
  // ─── Pages ────────────────────────────────────────────────────────────────
235
313
 
236
314
  async _dashboard(req, res) {
@@ -269,7 +347,7 @@ class Admin {
269
347
  const activityData = ActivityLog.recent(25);
270
348
  const activityTotals = ActivityLog.totals();
271
349
 
272
- res.render('pages/dashboard.njk', this._ctx(req, {
350
+ res.render('pages/dashboard.njk', this._ctxWithFlash(req, res, {
273
351
  pageTitle: 'Dashboard',
274
352
  activePage: 'dashboard',
275
353
  resources: resourceData,
@@ -286,10 +364,13 @@ class Admin {
286
364
  const R = this._resolve(req.params.resource, res);
287
365
  if (!R) return;
288
366
 
289
- const page = Number(req.query.page) || 1;
290
- const search = req.query.search || '';
291
- const sort = req.query.sort || 'id';
292
- const order = req.query.order || 'desc';
367
+ const page = Number(req.query.page) || 1;
368
+ const search = req.query.search || '';
369
+ const sort = req.query.sort || 'id';
370
+ const order = req.query.order || 'desc';
371
+ const perPage = Number(req.query.perPage) || R.perPage;
372
+ const year = req.query.year || null;
373
+ const month = req.query.month || null;
293
374
 
294
375
  // Collect active filters
295
376
  const activeFilters = {};
@@ -299,25 +380,30 @@ class Admin {
299
380
  }
300
381
  }
301
382
 
302
- const result = await R.fetchList({ page, search, sort, order, filters: activeFilters });
383
+ const result = await R.fetchList({ page, search, sort, order, perPage, filters: activeFilters, year, month });
303
384
  const rows = result.data.map(r => r.toJSON ? r.toJSON() : r);
304
385
 
305
386
  const listFields = R.fields()
306
387
  .filter(f => !f._hidden && !f._detailOnly)
307
388
  .map(f => f.toJSON());
308
389
 
309
- res.render('pages/list.njk', this._ctx(req, {
390
+ res.render('pages/list.njk', this._ctxWithFlash(req, res, {
310
391
  pageTitle: R._getLabel(),
311
392
  activeResource: req.params.resource,
312
393
  resource: {
313
- slug: R.slug,
314
- label: R._getLabel(),
315
- singular: R._getLabelSingular(),
316
- icon: R.icon,
317
- canCreate: R.canCreate,
318
- canEdit: R.canEdit,
319
- canDelete: R.canDelete,
320
- canView: R.canView,
394
+ slug: R.slug,
395
+ label: R._getLabel(),
396
+ singular: R._getLabelSingular(),
397
+ icon: R.icon,
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 || {},
321
407
  },
322
408
  rows,
323
409
  listFields,
@@ -331,6 +417,8 @@ class Admin {
331
417
  search,
332
418
  sort,
333
419
  order,
420
+ year,
421
+ month,
334
422
  }));
335
423
  } catch (err) {
336
424
  this._error(res, err);
@@ -343,7 +431,7 @@ class Admin {
343
431
  if (!R) return;
344
432
  if (!R.canCreate) return res.status(403).send('Not allowed');
345
433
 
346
- res.render('pages/form.njk', this._ctx(req, {
434
+ res.render('pages/form.njk', this._ctxWithFlash(req, res, {
347
435
  pageTitle: `New ${R._getLabelSingular()}`,
348
436
  activeResource: req.params.resource,
349
437
  resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: false },
@@ -364,14 +452,25 @@ class Admin {
364
452
  if (!R) return;
365
453
  if (!R.canCreate) return res.status(403).send('Not allowed');
366
454
 
367
- await R.create(req.body);
368
- ActivityLog.record('create', R.slug, null, `New ${R._getLabelSingular()}`);
455
+ const record = await R.create(req.body);
456
+ ActivityLog.record('create', R.slug, record?.id, `New ${R._getLabelSingular()}`);
457
+
458
+ const submit = req.body._submit || 'save';
459
+ if (submit === 'continue' && record?.id) {
460
+ AdminAuth.setFlash(res, 'success', `${R._getLabelSingular()} created. You may continue editing.`);
461
+ return res.redirect(`${this._config.prefix}/${R.slug}/${record.id}/edit`);
462
+ }
463
+ if (submit === 'add_another') {
464
+ AdminAuth.setFlash(res, 'success', `${R._getLabelSingular()} created. Add another below.`);
465
+ return res.redirect(`${this._config.prefix}/${R.slug}/create`);
466
+ }
467
+
369
468
  this._flash(req, 'success', `${R._getLabelSingular()} created successfully`);
370
469
  this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
371
470
  } catch (err) {
372
471
  if (err.status === 422) {
373
472
  const R = this._resources.get(req.params.resource);
374
- return res.render('pages/form.njk', this._ctx(req, {
473
+ return res.render('pages/form.njk', this._ctxWithFlash(req, res, {
375
474
  pageTitle: `New ${R._getLabelSingular()}`,
376
475
  activeResource: req.params.resource,
377
476
  resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: false },
@@ -395,7 +494,7 @@ class Admin {
395
494
  const record = await R.fetchOne(req.params.id);
396
495
  const data = record.toJSON ? record.toJSON() : record;
397
496
 
398
- res.render('pages/form.njk', this._ctx(req, {
497
+ res.render('pages/form.njk', this._ctxWithFlash(req, res, {
399
498
  pageTitle: `Edit ${R._getLabelSingular()} #${req.params.id}`,
400
499
  activeResource: req.params.resource,
401
500
  resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: R.canDelete },
@@ -421,13 +520,20 @@ class Admin {
421
520
  if (method === 'PUT' || method === 'POST') {
422
521
  await R.update(req.params.id, req.body);
423
522
  ActivityLog.record('update', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`);
523
+
524
+ const submit = req.body._submit || 'save';
525
+ if (submit === 'continue') {
526
+ AdminAuth.setFlash(res, 'success', 'Changes saved. You may continue editing.');
527
+ return res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}/edit`);
528
+ }
529
+
424
530
  this._flash(req, 'success', `${R._getLabelSingular()} updated successfully`);
425
531
  this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
426
532
  }
427
533
  } catch (err) {
428
534
  if (err.status === 422) {
429
535
  const R = this._resources.get(req.params.resource);
430
- return res.render('pages/form.njk', this._ctx(req, {
536
+ return res.render('pages/form.njk', this._ctxWithFlash(req, res, {
431
537
  pageTitle: `Edit ${R._getLabelSingular()} #${req.params.id}`,
432
538
  activeResource: req.params.resource,
433
539
  resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: R.canDelete },
@@ -471,29 +577,38 @@ class Admin {
471
577
  const record = await R.fetchOne(req.params.id);
472
578
  const data = record.toJSON ? record.toJSON() : record;
473
579
 
474
- // Build detail field groups (all non-hidden fields, grouped by tab)
475
580
  const detailFields = R.fields()
476
- .filter(f => f._type !== '__tab__' && !f._hidden && !f._listOnly)
581
+ .filter(f => f._type !== '__tab__' && f._type !== 'fieldset' && !f._hidden && !f._listOnly)
477
582
  .map(f => f.toJSON());
478
583
 
479
584
  const tabs = this._buildTabs(R.fields());
480
585
 
481
- res.render('pages/detail.njk', this._ctx(req, {
586
+ // Load inline related records
587
+ const inlineData = await Promise.all(
588
+ (R.inlines || []).map(async (inline) => {
589
+ const rows = await inline.fetchRows(data[R.model.primaryKey || 'id']);
590
+ return { ...inline.toJSON(), rows };
591
+ })
592
+ );
593
+
594
+ res.render('pages/detail.njk', this._ctxWithFlash(req, res, {
482
595
  pageTitle: `${R._getLabelSingular()} #${req.params.id}`,
483
596
  activeResource: req.params.resource,
484
597
  resource: {
485
- slug: R.slug,
486
- label: R._getLabel(),
487
- singular: R._getLabelSingular(),
488
- icon: R.icon,
489
- canEdit: R.canEdit,
490
- canDelete: R.canDelete,
491
- canCreate: R.canCreate,
598
+ slug: R.slug,
599
+ label: R._getLabel(),
600
+ singular: R._getLabelSingular(),
601
+ icon: R.icon,
602
+ canEdit: R.canEdit,
603
+ canDelete: R.canDelete,
604
+ canCreate: R.canCreate,
605
+ rowActions: R.rowActions || [],
492
606
  },
493
- record: data,
607
+ record: data,
494
608
  detailFields,
495
609
  tabs,
496
- hasTabs: tabs.length > 1,
610
+ hasTabs: tabs.length > 1,
611
+ inlines: inlineData,
497
612
  }));
498
613
  } catch (err) {
499
614
  this._error(res, err);
@@ -523,14 +638,72 @@ class Admin {
523
638
  }
524
639
  }
525
640
 
526
- // ─── Global search ────────────────────────────────────────────────────────
641
+ // ─── Bulk custom action ───────────────────────────────────────────────────
642
+
643
+ async _bulkAction(req, res) {
644
+ try {
645
+ const R = this._resolve(req.params.resource, res);
646
+ if (!R) return;
647
+
648
+ const actionIndex = Number(req.body.actionIndex);
649
+ const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids].filter(Boolean);
650
+ const action = (R.actions || [])[actionIndex];
651
+
652
+ if (!action) {
653
+ this._flash(req, 'error', 'Unknown action.');
654
+ return this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
655
+ }
656
+
657
+ if (!ids.length) {
658
+ this._flash(req, 'error', 'No records selected.');
659
+ return this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
660
+ }
661
+
662
+ await action.handler(ids, R.model);
663
+ ActivityLog.record('update', R.slug, null, `Bulk action "${action.label}" on ${ids.length} records`);
664
+ this._flash(req, 'success', `Action "${action.label}" applied to ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
665
+ this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
666
+ } catch (err) {
667
+ this._error(res, err);
668
+ }
669
+ }
670
+
671
+ // ─── Per-row custom action ────────────────────────────────────────────────
672
+
673
+ async _rowAction(req, res) {
674
+ try {
675
+ const R = this._resolve(req.params.resource, res);
676
+ if (!R) return;
677
+
678
+ const actionName = req.params.action;
679
+ const rowAction = (R.rowActions || []).find(a => a.action === actionName);
680
+
681
+ if (!rowAction || !rowAction.handler) {
682
+ return res.status(404).send(`Row action "${actionName}" not found.`);
683
+ }
684
+
685
+ const record = await R.fetchOne(req.params.id);
686
+ const result = await rowAction.handler(record, R.model);
687
+
688
+ // If handler returns a redirect URL, use it; otherwise go back to list
689
+ const redirect = (typeof result === 'string' && result.startsWith('/'))
690
+ ? result
691
+ : `${this._config.prefix}/${R.slug}`;
692
+
693
+ ActivityLog.record('update', R.slug, req.params.id, `Action "${rowAction.label}" on #${req.params.id}`);
694
+ this._flash(req, 'success', rowAction.successMessage || `Action "${rowAction.label}" completed.`);
695
+ this._redirectWithFlash(res, redirect, req._flashType, req._flashMessage);
696
+ } catch (err) {
697
+ this._error(res, err);
698
+ }
699
+ }
527
700
 
528
701
  async _search(req, res) {
529
702
  try {
530
703
  const q = (req.query.q || '').trim();
531
704
 
532
705
  if (!q) {
533
- return res.render('pages/search.njk', this._ctx(req, {
706
+ return res.render('pages/search.njk', this._ctxWithFlash(req, res, {
534
707
  pageTitle: 'Search',
535
708
  activePage: 'search',
536
709
  query: '',
@@ -564,7 +737,7 @@ class Admin {
564
737
  const filtered = results.filter(Boolean);
565
738
  const total = filtered.reduce((s, r) => s + r.total, 0);
566
739
 
567
- res.render('pages/search.njk', this._ctx(req, {
740
+ res.render('pages/search.njk', this._ctxWithFlash(req, res, {
568
741
  pageTitle: `Search: ${q}`,
569
742
  activePage: 'search',
570
743
  query: q,
@@ -641,25 +814,32 @@ class Admin {
641
814
  * them as text instead of inputs.
642
815
  */
643
816
  _formFields(R) {
644
- const readonlySet = new Set(R.readonlyFields || []);
645
- let currentTab = null;
646
- const result = [];
817
+ const readonlySet = new Set(R.readonlyFields || []);
818
+ const prepopFields = R.prepopulatedFields || {};
819
+ let currentTab = null;
820
+ let currentFieldset = null;
821
+ const result = [];
647
822
 
648
823
  for (const f of R.fields()) {
649
- // Tab separator — update current tab context, don't add to form fields
650
824
  if (f._type === 'tab') {
651
- currentTab = f._label;
825
+ currentTab = f._label;
826
+ currentFieldset = null;
827
+ continue;
828
+ }
829
+ if (f._type === 'fieldset') {
830
+ // Include fieldset headers as sentinel objects
831
+ result.push({ _isFieldset: true, label: f._label, tab: currentTab });
832
+ currentFieldset = f._label;
652
833
  continue;
653
834
  }
654
-
655
- // Skip id, list-only, and fully hidden fields
656
835
  if (f._type === 'id' || f._listOnly || f._hidden) continue;
657
836
 
658
- const json = f.toJSON();
659
- json.tab = currentTab;
660
- json.required = !f._nullable;
837
+ const json = f.toJSON();
838
+ json.tab = currentTab;
839
+ json.fieldset = currentFieldset;
840
+ json.required = !f._nullable;
841
+ json.prepopulate = prepopFields[f._name] || f._prepopulate || null;
661
842
 
662
- // Mark as readonly if in readonlyFields array or flagged on field itself
663
843
  if (readonlySet.has(f._name) || f._readonly) {
664
844
  json.isReadonly = true;
665
845
  }
@@ -685,11 +865,14 @@ class Admin {
685
865
  tabs.push(current);
686
866
  continue;
687
867
  }
688
- if (f._hidden || f._listOnly) continue;
689
- if (!current) {
690
- current = { label: null, fields: [] };
691
- tabs.push(current);
868
+ if (f._type === 'fieldset') {
869
+ // Fieldsets are embedded as sentinel entries within a tab's fields
870
+ if (!current) { current = { label: null, fields: [] }; tabs.push(current); }
871
+ current.fields.push({ _isFieldset: true, label: f._label });
872
+ continue;
692
873
  }
874
+ if (f._hidden || f._listOnly) continue;
875
+ if (!current) { current = { label: null, fields: [] }; tabs.push(current); }
693
876
  current.fields.push(f.toJSON());
694
877
  }
695
878
 
@@ -726,26 +909,21 @@ class Admin {
726
909
  `);
727
910
  }
728
911
 
729
- // ─── Flash messages (stored in query string for stateless operation) ───────
912
+ // ─── Flash (cookie-based) ─────────────────────────────────────────────────
913
+
730
914
  _flash(req, type, message) {
731
- // Store in a simple query param on redirect
732
915
  req._flashType = type;
733
916
  req._flashMessage = message;
734
917
  }
735
918
 
736
919
  _pullFlash(req) {
737
- const type = req.query._flash_type;
738
- const msg = req.query._flash_msg;
739
- if (type && msg) return { [type]: decodeURIComponent(msg) };
740
- // Check set by _flash helper (pre-redirect)
741
920
  if (req._flashType) return { [req._flashType]: req._flashMessage };
742
921
  return {};
743
922
  }
744
923
 
745
- // Override redirect to include flash in URL
746
924
  _redirectWithFlash(res, url, type, message) {
747
- const sep = url.includes('?') ? '&' : '?';
748
- res.redirect(`${url}${sep}_flash_type=${type}&_flash_msg=${encodeURIComponent(message)}`);
925
+ if (type && message) AdminAuth.setFlash(res, type, message);
926
+ res.redirect(url);
749
927
  }
750
928
 
751
929
  _autoResource(ModelClass) {
@@ -764,3 +942,4 @@ module.exports.Admin = Admin;
764
942
  module.exports.AdminResource = AdminResource;
765
943
  module.exports.AdminField = AdminField;
766
944
  module.exports.AdminFilter = AdminFilter;
945
+ module.exports.AdminInline = AdminInline;