millas 0.2.12-beta-2 → 0.2.13-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/admin/Admin.js +122 -38
- package/src/admin/ViewContext.js +12 -3
- package/src/admin/resources/AdminResource.js +10 -0
- package/src/admin/static/admin.css +95 -14
- package/src/admin/views/layouts/base.njk +23 -34
- package/src/admin/views/pages/detail.njk +16 -5
- package/src/admin/views/pages/error.njk +65 -0
- package/src/admin/views/pages/list.njk +127 -2
- package/src/admin/views/partials/form-scripts.njk +7 -3
- package/src/admin/views/partials/form-widget.njk +2 -1
- package/src/admin/views/partials/icons.njk +64 -0
- package/src/ai/AIManager.js +954 -0
- package/src/ai/AITokenBudget.js +250 -0
- package/src/ai/PromptGuard.js +216 -0
- package/src/ai/agents.js +218 -0
- package/src/ai/conversation.js +213 -0
- package/src/ai/drivers.js +734 -0
- package/src/ai/files.js +249 -0
- package/src/ai/media.js +303 -0
- package/src/ai/pricing.js +152 -0
- package/src/ai/provider_tools.js +114 -0
- package/src/ai/types.js +356 -0
- package/src/commands/createsuperuser.js +17 -4
- package/src/commands/serve.js +2 -4
- package/src/container/AppInitializer.js +39 -15
- package/src/container/Application.js +31 -1
- package/src/core/foundation.js +1 -1
- package/src/errors/HttpError.js +32 -16
- package/src/facades/AI.js +411 -0
- package/src/facades/Hash.js +67 -0
- package/src/facades/Process.js +144 -0
- package/src/hashing/Hash.js +262 -0
- package/src/http/HtmlEscape.js +162 -0
- package/src/http/MillasRequest.js +63 -7
- package/src/http/MillasResponse.js +70 -4
- package/src/http/ResponseDispatcher.js +21 -27
- package/src/http/SafeFilePath.js +195 -0
- package/src/http/SafeRedirect.js +62 -0
- package/src/http/SecurityBootstrap.js +70 -0
- package/src/http/helpers.js +40 -125
- package/src/http/index.js +10 -1
- package/src/http/middleware/CsrfMiddleware.js +258 -0
- package/src/http/middleware/RateLimiter.js +314 -0
- package/src/http/middleware/SecurityHeaders.js +281 -0
- package/src/i18n/Translator.js +10 -2
- package/src/logger/LogRedactor.js +247 -0
- package/src/logger/Logger.js +1 -1
- package/src/logger/formatters/JsonFormatter.js +11 -4
- package/src/logger/formatters/PrettyFormatter.js +3 -1
- package/src/logger/formatters/SimpleFormatter.js +14 -3
- package/src/middleware/ThrottleMiddleware.js +27 -4
- package/src/process/Process.js +333 -0
- package/src/router/MiddlewareRegistry.js +27 -2
- package/src/scaffold/templates.js +3 -0
- package/src/validation/Validator.js +348 -607
- package/src/admin.zip +0 -0
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "millas",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13-beta",
|
|
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": {
|
|
7
7
|
".": "./src/index.js",
|
|
8
8
|
"./core/*": "./src/core/*.js",
|
|
9
|
+
"./middleware/*": "./src/middleware/*.js",
|
|
9
10
|
"./facades/*": "./src/facades/*.js"
|
|
10
11
|
},
|
|
11
12
|
"bin": {
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"nodemailer": "^6.9.0",
|
|
48
49
|
"nunjucks": "^3.2.4",
|
|
49
50
|
"ora": "5.4.1",
|
|
50
|
-
"sqlite3": "^
|
|
51
|
+
"sqlite3": "^6.0.1"
|
|
51
52
|
},
|
|
52
53
|
"peerDependencies": {
|
|
53
54
|
"express": "^4.18.0"
|
package/src/admin/Admin.js
CHANGED
|
@@ -8,6 +8,8 @@ const { FormGenerator } = require('./FormGenerator');
|
|
|
8
8
|
const { ViewContext } = require('./ViewContext');
|
|
9
9
|
const AdminAuth = require('./AdminAuth');
|
|
10
10
|
const { AdminResource, AdminField, AdminFilter, AdminInline } = require('./resources/AdminResource');
|
|
11
|
+
const LookupParser = require('../orm/query/LookupParser');
|
|
12
|
+
const Facade = require('../facades/Facade');
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Admin
|
|
@@ -148,6 +150,17 @@ class Admin {
|
|
|
148
150
|
});
|
|
149
151
|
|
|
150
152
|
// ── Custom filters ───────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
// Resolve a fkResource table name to the registered admin slug (or null)
|
|
155
|
+
const resolveFkSlug = (tableName) => {
|
|
156
|
+
if (!tableName) return null;
|
|
157
|
+
if (this._resources.has(tableName)) return tableName;
|
|
158
|
+
for (const R of this._resources.values()) {
|
|
159
|
+
if (R.model && R.model.table === tableName) return R.slug;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
};
|
|
163
|
+
|
|
151
164
|
env.addFilter('adminCell', (value, field) => {
|
|
152
165
|
if (value === null || value === undefined) return '<span class="cell-muted">—</span>';
|
|
153
166
|
switch (field.type) {
|
|
@@ -189,6 +202,14 @@ class Admin {
|
|
|
189
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>`;
|
|
190
203
|
case 'richtext':
|
|
191
204
|
return `<div style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-soft)">${String(value).replace(/<[^>]+>/g, '').slice(0, 80)}</div>`;
|
|
205
|
+
case 'fk': {
|
|
206
|
+
const fkSlug = resolveFkSlug(field.fkResource);
|
|
207
|
+
const prefix = this._config.prefix || '/admin';
|
|
208
|
+
if (fkSlug) {
|
|
209
|
+
return `<span class="fk-cell">${value}<a class="fk-arrow-btn" href="${prefix}/${fkSlug}/${value}" title="View record #${value}"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></span>`;
|
|
210
|
+
}
|
|
211
|
+
return String(value);
|
|
212
|
+
}
|
|
192
213
|
default: {
|
|
193
214
|
const str = String(value);
|
|
194
215
|
return str.length > 60
|
|
@@ -243,6 +264,14 @@ class Admin {
|
|
|
243
264
|
const c2 = (field.colors && field.colors[String(value)]) || colorMap2[String(value)] || 'gray';
|
|
244
265
|
return `<span class="badge badge-${c2}">${value}</span>`;
|
|
245
266
|
}
|
|
267
|
+
case 'fk': {
|
|
268
|
+
const fkSlug = resolveFkSlug(field.fkResource);
|
|
269
|
+
const prefix = this._config.prefix || '/admin';
|
|
270
|
+
if (fkSlug) {
|
|
271
|
+
return `<span class="fk-cell fk-cell-detail">${value}<a class="fk-arrow-btn" href="${prefix}/${fkSlug}/${value}" title="View record #${value}"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></span>`;
|
|
272
|
+
}
|
|
273
|
+
return String(value);
|
|
274
|
+
}
|
|
246
275
|
default: {
|
|
247
276
|
const str = String(value);
|
|
248
277
|
return str;
|
|
@@ -279,6 +308,26 @@ class Admin {
|
|
|
279
308
|
// ─── Base render context ──────────────────────────────────────────────────
|
|
280
309
|
|
|
281
310
|
_ctx(req, extra = {}) {
|
|
311
|
+
// Resolve the auth user model from the container so we can tag its
|
|
312
|
+
// resource as 'auth' in the sidebar — automatic, no dev config needed.
|
|
313
|
+
let authUserModel = null;
|
|
314
|
+
try {
|
|
315
|
+
const container = Facade._container;
|
|
316
|
+
if (container) {
|
|
317
|
+
const auth = container.make('auth');
|
|
318
|
+
authUserModel = auth?._UserModel || null;
|
|
319
|
+
}
|
|
320
|
+
} catch { /* container not booted yet or auth not registered */ }
|
|
321
|
+
|
|
322
|
+
// A resource is in the 'auth' category if:
|
|
323
|
+
// 1. Its model is the configured auth_user model, OR
|
|
324
|
+
// 2. The developer explicitly set static authCategory = 'auth'
|
|
325
|
+
const isAuthResource = (r) => {
|
|
326
|
+
if (r.authCategory === 'auth') return true;
|
|
327
|
+
if (authUserModel && r.model && r.model === authUserModel) return true;
|
|
328
|
+
return false;
|
|
329
|
+
};
|
|
330
|
+
|
|
282
331
|
return {
|
|
283
332
|
csrfToken: AdminAuth.enabled ? AdminAuth.csrfToken(req) : 'disabled',
|
|
284
333
|
adminPrefix: this._config.prefix,
|
|
@@ -294,6 +343,7 @@ class Admin {
|
|
|
294
343
|
icon: r.icon,
|
|
295
344
|
canView: r.hasPermission(req.adminUser || null, 'view'),
|
|
296
345
|
index: idx + 1,
|
|
346
|
+
category: isAuthResource(r) ? 'auth' : 'app',
|
|
297
347
|
})),
|
|
298
348
|
flash: extra._flash || {},
|
|
299
349
|
activePage: extra.activePage || null,
|
|
@@ -406,7 +456,7 @@ class Admin {
|
|
|
406
456
|
activityTotals,
|
|
407
457
|
}));
|
|
408
458
|
} catch (err) {
|
|
409
|
-
this._error(res, err);
|
|
459
|
+
this._error(req, res, err);
|
|
410
460
|
}
|
|
411
461
|
}
|
|
412
462
|
|
|
@@ -449,7 +499,7 @@ class Admin {
|
|
|
449
499
|
baseCtx: this._ctxWithFlash(req, res, {}),
|
|
450
500
|
}), R);
|
|
451
501
|
} catch (err) {
|
|
452
|
-
this._error(res, err);
|
|
502
|
+
this._error(req, res, err);
|
|
453
503
|
}
|
|
454
504
|
}
|
|
455
505
|
|
|
@@ -465,7 +515,7 @@ class Admin {
|
|
|
465
515
|
baseCtx: this._ctxWithFlash(req, res, {}),
|
|
466
516
|
}), R);
|
|
467
517
|
} catch (err) {
|
|
468
|
-
this._error(res, err);
|
|
518
|
+
this._error(req, res, err);
|
|
469
519
|
}
|
|
470
520
|
}
|
|
471
521
|
|
|
@@ -502,7 +552,7 @@ class Admin {
|
|
|
502
552
|
baseCtx: this._ctxWithFlash(req, res, {}),
|
|
503
553
|
}), R);
|
|
504
554
|
}
|
|
505
|
-
this._error(res, err);
|
|
555
|
+
this._error(req, res, err);
|
|
506
556
|
}
|
|
507
557
|
}
|
|
508
558
|
|
|
@@ -524,7 +574,7 @@ class Admin {
|
|
|
524
574
|
baseCtx: this._ctxWithFlash(req, res, {}),
|
|
525
575
|
}), R);
|
|
526
576
|
} catch (err) {
|
|
527
|
-
this._error(res, err);
|
|
577
|
+
this._error(req, res, err);
|
|
528
578
|
}
|
|
529
579
|
}
|
|
530
580
|
|
|
@@ -563,7 +613,7 @@ class Admin {
|
|
|
563
613
|
baseCtx: this._ctxWithFlash(req, res, {}),
|
|
564
614
|
}), R);
|
|
565
615
|
}
|
|
566
|
-
this._error(res, err);
|
|
616
|
+
this._error(req, res, err);
|
|
567
617
|
}
|
|
568
618
|
}
|
|
569
619
|
|
|
@@ -579,7 +629,7 @@ class Admin {
|
|
|
579
629
|
this._flash(req, 'success', `${R._getLabelSingular()} deleted`);
|
|
580
630
|
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
581
631
|
} catch (err) {
|
|
582
|
-
this._error(res, err);
|
|
632
|
+
this._error(req, res, err);
|
|
583
633
|
}
|
|
584
634
|
}
|
|
585
635
|
|
|
@@ -618,7 +668,7 @@ class Admin {
|
|
|
618
668
|
baseCtx: this._ctxWithFlash(req, res, {}),
|
|
619
669
|
}), R);
|
|
620
670
|
} catch (err) {
|
|
621
|
-
this._error(res, err);
|
|
671
|
+
this._error(req, res, err);
|
|
622
672
|
}
|
|
623
673
|
}
|
|
624
674
|
|
|
@@ -642,7 +692,7 @@ class Admin {
|
|
|
642
692
|
this._flash(req, 'success', `Deleted ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
|
|
643
693
|
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
644
694
|
} catch (err) {
|
|
645
|
-
this._error(res, err);
|
|
695
|
+
this._error(req, res, err);
|
|
646
696
|
}
|
|
647
697
|
}
|
|
648
698
|
|
|
@@ -673,7 +723,7 @@ class Admin {
|
|
|
673
723
|
this._flash(req, 'success', `Action "${action.label}" applied to ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
|
|
674
724
|
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
675
725
|
} catch (err) {
|
|
676
|
-
this._error(res, err);
|
|
726
|
+
this._error(req, res, err);
|
|
677
727
|
}
|
|
678
728
|
}
|
|
679
729
|
|
|
@@ -704,7 +754,7 @@ class Admin {
|
|
|
704
754
|
this._flash(req, 'success', rowAction.successMessage || `Action "${rowAction.label}" completed.`);
|
|
705
755
|
this._redirectWithFlash(res, redirect, req._flashType, req._flashMessage);
|
|
706
756
|
} catch (err) {
|
|
707
|
-
this._error(res, err);
|
|
757
|
+
this._error(req, res, err);
|
|
708
758
|
}
|
|
709
759
|
}
|
|
710
760
|
|
|
@@ -772,17 +822,49 @@ class Admin {
|
|
|
772
822
|
}
|
|
773
823
|
labelCol = labelCol || pk;
|
|
774
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
|
+
|
|
775
857
|
// Call _db() separately for each query — knex builders are mutable,
|
|
776
858
|
// reusing the same instance across count + select corrupts both queries.
|
|
777
|
-
let countQ = R.model._db().count(`${pk} as total`);
|
|
859
|
+
let countQ = applyScope(R.model._db().count(`${pk} as total`));
|
|
778
860
|
if (search) countQ = countQ.where(labelCol, 'like', `%${search}%`);
|
|
779
861
|
const [{ total }] = await countQ;
|
|
780
862
|
|
|
781
|
-
let rowQ = R.model._db()
|
|
863
|
+
let rowQ = applyScope(R.model._db()
|
|
782
864
|
.select([`${pk} as id`, `${labelCol} as label`])
|
|
783
865
|
.orderBy(labelCol, 'asc')
|
|
784
866
|
.limit(perPage)
|
|
785
|
-
.offset(offset);
|
|
867
|
+
.offset(offset));
|
|
786
868
|
if (search) rowQ = rowQ.where(labelCol, 'like', `%${search}%`);
|
|
787
869
|
const rows = await rowQ;
|
|
788
870
|
|
|
@@ -834,7 +916,7 @@ class Admin {
|
|
|
834
916
|
AdminAuth.setFlash(res, 'success', `${inline.label} added.`);
|
|
835
917
|
res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}`);
|
|
836
918
|
} catch (err) {
|
|
837
|
-
this._error(res, err);
|
|
919
|
+
this._error(req, res, err);
|
|
838
920
|
}
|
|
839
921
|
}
|
|
840
922
|
|
|
@@ -860,7 +942,7 @@ class Admin {
|
|
|
860
942
|
AdminAuth.setFlash(res, 'success', 'Record deleted.');
|
|
861
943
|
res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}`);
|
|
862
944
|
} catch (err) {
|
|
863
|
-
this._error(res, err);
|
|
945
|
+
this._error(req, res, err);
|
|
864
946
|
}
|
|
865
947
|
}
|
|
866
948
|
|
|
@@ -907,7 +989,7 @@ class Admin {
|
|
|
907
989
|
baseCtx: this._ctxWithFlash(req, res, { activePage: 'search' }),
|
|
908
990
|
}));
|
|
909
991
|
} catch (err) {
|
|
910
|
-
this._error(res, err);
|
|
992
|
+
this._error(req, res, err);
|
|
911
993
|
}
|
|
912
994
|
}
|
|
913
995
|
|
|
@@ -963,7 +1045,7 @@ class Admin {
|
|
|
963
1045
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
|
|
964
1046
|
res.send([header, ...csvRows].join('\r\n'));
|
|
965
1047
|
} catch (err) {
|
|
966
|
-
this._error(res, err);
|
|
1048
|
+
this._error(req, res, err);
|
|
967
1049
|
}
|
|
968
1050
|
}
|
|
969
1051
|
|
|
@@ -1084,7 +1166,7 @@ class Admin {
|
|
|
1084
1166
|
finalCtx = hookCtx.templateCtx || ctx;
|
|
1085
1167
|
} catch (err) {
|
|
1086
1168
|
// before_render errors abort the render — surface as a 500
|
|
1087
|
-
return this._error(res, err);
|
|
1169
|
+
return this._error(req, res, err);
|
|
1088
1170
|
}
|
|
1089
1171
|
|
|
1090
1172
|
// ── Render ──────────────────────────────────────────────────────────
|
|
@@ -1121,25 +1203,27 @@ class Admin {
|
|
|
1121
1203
|
return R;
|
|
1122
1204
|
}
|
|
1123
1205
|
|
|
1124
|
-
_error(res, err) {
|
|
1125
|
-
const status
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1206
|
+
_error(req, res, err) {
|
|
1207
|
+
const status = err.status || 500;
|
|
1208
|
+
const is404 = status === 404;
|
|
1209
|
+
const title = is404 ? 'Not found' : `Error ${status}`;
|
|
1210
|
+
const message = err.message || 'An unexpected error occurred.';
|
|
1211
|
+
const stack = process.env.NODE_ENV !== 'production' && !is404 ? (err.stack || '') : '';
|
|
1212
|
+
|
|
1213
|
+
try {
|
|
1214
|
+
const ctx = this._ctxWithFlash(req, res, {
|
|
1215
|
+
pageTitle: title,
|
|
1216
|
+
errorStatus: status,
|
|
1217
|
+
errorTitle: title,
|
|
1218
|
+
errorMsg: message,
|
|
1219
|
+
errorStack: stack,
|
|
1220
|
+
});
|
|
1221
|
+
res.status(status);
|
|
1222
|
+
return this._render(req, res, 'pages/error.njk', ctx);
|
|
1223
|
+
} catch (_renderErr) {
|
|
1224
|
+
// Fallback if template itself fails
|
|
1225
|
+
res.status(status).send(`<pre>${message}</pre>`);
|
|
1226
|
+
}
|
|
1143
1227
|
}
|
|
1144
1228
|
|
|
1145
1229
|
// ─── Flash (cookie-based) ─────────────────────────────────────────────────
|
package/src/admin/ViewContext.js
CHANGED
|
@@ -136,7 +136,13 @@ class ViewContext {
|
|
|
136
136
|
dateHierarchy: Resource.dateHierarchy || null,
|
|
137
137
|
prepopulatedFields: Resource.prepopulatedFields || {},
|
|
138
138
|
},
|
|
139
|
-
rows
|
|
139
|
+
rows: rows.map(row => ({
|
|
140
|
+
...row,
|
|
141
|
+
_rowActions: (Resource.rowActions || []).map(ra => ({
|
|
142
|
+
...ra,
|
|
143
|
+
href: typeof ra.href === 'function' ? ra.href(row) : ra.href,
|
|
144
|
+
})),
|
|
145
|
+
})),
|
|
140
146
|
listFields,
|
|
141
147
|
filters: Resource.filters().map(f => f.toJSON()),
|
|
142
148
|
activeFilters,
|
|
@@ -246,7 +252,10 @@ class ViewContext {
|
|
|
246
252
|
canEdit: perms.canEdit,
|
|
247
253
|
canDelete: perms.canDelete,
|
|
248
254
|
canCreate: perms.canCreate,
|
|
249
|
-
rowActions: Resource.rowActions || []
|
|
255
|
+
rowActions: (Resource.rowActions || []).map(ra => ({
|
|
256
|
+
...ra,
|
|
257
|
+
href: typeof ra.href === 'function' ? ra.href(record) : ra.href,
|
|
258
|
+
})),
|
|
250
259
|
},
|
|
251
260
|
record,
|
|
252
261
|
detailFields,
|
|
@@ -306,4 +315,4 @@ class ViewContext {
|
|
|
306
315
|
}
|
|
307
316
|
}
|
|
308
317
|
|
|
309
|
-
module.exports = { ViewContext };
|
|
318
|
+
module.exports = { ViewContext };
|
|
@@ -641,6 +641,15 @@ class AdminField {
|
|
|
641
641
|
* e.g. AdminField.text('slug').prepopulate('title')
|
|
642
642
|
*/
|
|
643
643
|
prepopulate(src) { this._prepopulate = src; return this; }
|
|
644
|
+
/**
|
|
645
|
+
* Scope the FK dropdown to records matching these constraints.
|
|
646
|
+
* Accepts a plain object (col = val pairs) or a function for advanced queries.
|
|
647
|
+
*
|
|
648
|
+
* AdminField.fk('tenant_id', 'users').where({ role: 'tenant' })
|
|
649
|
+
* AdminField.fk('landlord_id','users').where({ role: 'landlord', is_active: true })
|
|
650
|
+
* AdminField.fk('user_id', 'users').where(q => q.whereIn('role', ['admin', 'moderator']))
|
|
651
|
+
*/
|
|
652
|
+
where(constraints) { this._fkWhere = constraints; return this; }
|
|
644
653
|
|
|
645
654
|
// ─── Serialise ─────────────────────────────────────────────────────────────
|
|
646
655
|
|
|
@@ -665,6 +674,7 @@ class AdminField {
|
|
|
665
674
|
max: this._max,
|
|
666
675
|
isLink: this._isLink || false,
|
|
667
676
|
fkResource: this._fkResource || null,
|
|
677
|
+
fkWhere: this._fkWhere || null,
|
|
668
678
|
m2mResource: this._m2mResource || null,
|
|
669
679
|
prepopulate: this._prepopulate || null,
|
|
670
680
|
};
|
|
@@ -333,18 +333,18 @@
|
|
|
333
333
|
════════════════════════════════════════ */
|
|
334
334
|
.stats-grid {
|
|
335
335
|
display: grid;
|
|
336
|
-
grid-template-columns: repeat(auto-fill, minmax(
|
|
337
|
-
gap:
|
|
338
|
-
margin-bottom:
|
|
336
|
+
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
337
|
+
gap: 10px;
|
|
338
|
+
margin-bottom: 18px;
|
|
339
339
|
}
|
|
340
340
|
.stat-card {
|
|
341
341
|
background: var(--surface);
|
|
342
342
|
border: 1px solid var(--border);
|
|
343
|
-
border-radius: var(--radius
|
|
344
|
-
padding:
|
|
343
|
+
border-radius: var(--radius);
|
|
344
|
+
padding: 10px 12px;
|
|
345
345
|
display: flex;
|
|
346
346
|
flex-direction: column;
|
|
347
|
-
gap:
|
|
347
|
+
gap: 4px;
|
|
348
348
|
transition: box-shadow .15s, border-color .15s;
|
|
349
349
|
text-decoration: none;
|
|
350
350
|
box-shadow: var(--shadow-sm);
|
|
@@ -354,28 +354,28 @@
|
|
|
354
354
|
border-color: var(--primary-dim);
|
|
355
355
|
}
|
|
356
356
|
.stat-icon-wrap {
|
|
357
|
-
width:
|
|
358
|
-
border-radius:
|
|
357
|
+
width: 28px; height: 28px;
|
|
358
|
+
border-radius: 7px;
|
|
359
359
|
background: linear-gradient(135deg, #fff4e6 0%, #ffe4c4 100%);
|
|
360
360
|
display: flex; align-items: center; justify-content: center;
|
|
361
361
|
color: var(--primary);
|
|
362
|
-
margin-bottom:
|
|
362
|
+
margin-bottom: 2px;
|
|
363
363
|
}
|
|
364
364
|
.stat-label {
|
|
365
|
-
font-size:
|
|
365
|
+
font-size: 10.5px;
|
|
366
366
|
color: var(--text-muted);
|
|
367
367
|
font-weight: 500;
|
|
368
368
|
text-transform: uppercase;
|
|
369
|
-
letter-spacing: 0.
|
|
369
|
+
letter-spacing: 0.3px;
|
|
370
370
|
}
|
|
371
371
|
.stat-value {
|
|
372
|
-
font-size:
|
|
372
|
+
font-size: 20px;
|
|
373
373
|
font-weight: 700;
|
|
374
374
|
color: var(--text);
|
|
375
375
|
line-height: 1;
|
|
376
|
-
letter-spacing: -0.
|
|
376
|
+
letter-spacing: -0.3px;
|
|
377
377
|
}
|
|
378
|
-
.stat-sub { font-size:
|
|
378
|
+
.stat-sub { font-size: 11px; color: var(--text-muted); }
|
|
379
379
|
|
|
380
380
|
/* ════════════════════════════════════════
|
|
381
381
|
TABLE
|
|
@@ -1338,4 +1338,85 @@
|
|
|
1338
1338
|
}
|
|
1339
1339
|
.toggle-input:disabled ~ .toggle-label {
|
|
1340
1340
|
opacity: .5;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/* ─────────────────────────────────────────────────────────────────
|
|
1344
|
+
Thin themed scrollbars
|
|
1345
|
+
───────────────────────────────────────────────────────────────── */
|
|
1346
|
+
|
|
1347
|
+
/* Webkit (Chrome, Edge, Safari) */
|
|
1348
|
+
::-webkit-scrollbar {
|
|
1349
|
+
width: 5px;
|
|
1350
|
+
height: 5px;
|
|
1351
|
+
}
|
|
1352
|
+
::-webkit-scrollbar-track {
|
|
1353
|
+
background: transparent;
|
|
1354
|
+
}
|
|
1355
|
+
::-webkit-scrollbar-thumb {
|
|
1356
|
+
background: var(--primary-dim);
|
|
1357
|
+
border-radius: 99px;
|
|
1358
|
+
}
|
|
1359
|
+
::-webkit-scrollbar-thumb:hover {
|
|
1360
|
+
background: var(--primary);
|
|
1361
|
+
}
|
|
1362
|
+
::-webkit-scrollbar-corner {
|
|
1363
|
+
background: transparent;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/* Firefox */
|
|
1367
|
+
* {
|
|
1368
|
+
scrollbar-width: thin;
|
|
1369
|
+
scrollbar-color: var(--primary-dim) transparent;
|
|
1370
|
+
}
|
|
1371
|
+
*:hover {
|
|
1372
|
+
scrollbar-color: var(--primary) transparent;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
/* ─────────────────────────────────────────────────────────────────
|
|
1377
|
+
FK cells — floating arrow button appears on cell hover
|
|
1378
|
+
───────────────────────────────────────────────────────────────── */
|
|
1379
|
+
|
|
1380
|
+
/* The td needs position:relative — applied via the wrapper span signal */
|
|
1381
|
+
td:has(.fk-cell) {
|
|
1382
|
+
position: relative;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
.fk-cell {
|
|
1386
|
+
display: block;
|
|
1387
|
+
/* leave room so text never sits under the arrow */
|
|
1388
|
+
padding-right: 24px;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
.fk-arrow-btn {
|
|
1392
|
+
position: absolute;
|
|
1393
|
+
right: 8px;
|
|
1394
|
+
top: 50%;
|
|
1395
|
+
transform: translateY(-50%) translateX(4px);
|
|
1396
|
+
opacity: 0;
|
|
1397
|
+
transition: opacity .12s ease, transform .12s ease;
|
|
1398
|
+
color: var(--primary);
|
|
1399
|
+
display: inline-flex;
|
|
1400
|
+
align-items: center;
|
|
1401
|
+
justify-content: center;
|
|
1402
|
+
width: 22px;
|
|
1403
|
+
height: 22px;
|
|
1404
|
+
border-radius: var(--radius-sm);
|
|
1405
|
+
text-decoration: none;
|
|
1406
|
+
background: var(--primary-soft);
|
|
1407
|
+
border: 1px solid var(--primary-dim);
|
|
1408
|
+
pointer-events: none;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/* Show on td hover */
|
|
1412
|
+
td:hover .fk-arrow-btn {
|
|
1413
|
+
opacity: 1;
|
|
1414
|
+
transform: translateY(-50%) translateX(0);
|
|
1415
|
+
pointer-events: auto;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/* Detail page variant */
|
|
1419
|
+
.fk-cell-detail .fk-arrow-btn {
|
|
1420
|
+
width: 24px;
|
|
1421
|
+
height: 24px;
|
|
1341
1422
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
9
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
10
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
11
|
-
<link rel="stylesheet" href="{{ adminPrefix }}/static/admin.css?v=
|
|
11
|
+
<link rel="stylesheet" href="{{ adminPrefix }}/static/admin.css?v=21">
|
|
12
12
|
<link rel="stylesheet" href="{{ adminPrefix }}/static/json-editor.css?v=4">
|
|
13
13
|
<link rel="stylesheet" href="{{ adminPrefix }}/static/date-picker.css?v=3">
|
|
14
14
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js" crossorigin="anonymous"></script>
|
|
@@ -19,38 +19,7 @@
|
|
|
19
19
|
{# ══════════════════════════════════════
|
|
20
20
|
SVG ICON DEFINITIONS (hidden sprite)
|
|
21
21
|
══════════════════════════════════════ #}
|
|
22
|
-
|
|
23
|
-
<symbol id="ic-home" viewBox="0 0 24 24"><path d="M3 9.5L12 3l9 6.5V20a1 1 0 01-1 1H5a1 1 0 01-1-1V9.5z"/><path d="M9 21V12h6v9"/></symbol>
|
|
24
|
-
<symbol id="ic-table" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 3v18"/></symbol>
|
|
25
|
-
<symbol id="ic-users" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></symbol>
|
|
26
|
-
<symbol id="ic-file" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></symbol>
|
|
27
|
-
<symbol id="ic-tag" viewBox="0 0 24 24"><path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z"/><circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none"/></symbol>
|
|
28
|
-
<symbol id="ic-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></symbol>
|
|
29
|
-
<symbol id="ic-plus" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></symbol>
|
|
30
|
-
<symbol id="ic-edit" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></symbol>
|
|
31
|
-
<symbol id="ic-trash" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></symbol>
|
|
32
|
-
<symbol id="ic-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></symbol>
|
|
33
|
-
<symbol id="ic-filter" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></symbol>
|
|
34
|
-
<symbol id="ic-chevron-left" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></symbol>
|
|
35
|
-
<symbol id="ic-chevron-right" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></symbol>
|
|
36
|
-
<symbol id="ic-chevron-down" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></symbol>
|
|
37
|
-
<symbol id="ic-x" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></symbol>
|
|
38
|
-
<symbol id="ic-check" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></symbol>
|
|
39
|
-
<symbol id="ic-alert-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></symbol>
|
|
40
|
-
<symbol id="ic-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></symbol>
|
|
41
|
-
<symbol id="ic-eye" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></symbol>
|
|
42
|
-
<symbol id="ic-download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol>
|
|
43
|
-
<symbol id="ic-upload" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></symbol>
|
|
44
|
-
<symbol id="ic-refresh" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></symbol>
|
|
45
|
-
<symbol id="ic-more-vertical" viewBox="0 0 24 24"><circle cx="12" cy="5" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="19" r="1" fill="currentColor" stroke="none"/></symbol>
|
|
46
|
-
<symbol id="ic-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></symbol>
|
|
47
|
-
<symbol id="ic-list" viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></symbol>
|
|
48
|
-
<symbol id="ic-save" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></symbol>
|
|
49
|
-
<symbol id="ic-arrow-left" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></symbol>
|
|
50
|
-
<symbol id="ic-database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></symbol>
|
|
51
|
-
<symbol id="ic-activity" viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></symbol>
|
|
52
|
-
<symbol id="ic-log-out" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></symbol>
|
|
53
|
-
</svg>
|
|
22
|
+
{% include "partials/icons.njk" %}
|
|
54
23
|
|
|
55
24
|
{# ══════════════════════════════════════
|
|
56
25
|
SIDEBAR
|
|
@@ -80,14 +49,34 @@
|
|
|
80
49
|
<div class="nav-section">
|
|
81
50
|
<div class="nav-label">Resources</div>
|
|
82
51
|
{% for resource in resources %}
|
|
52
|
+
{% if resource.category != 'auth' %}
|
|
83
53
|
<a href="{{ adminPrefix }}/{{ resource.slug }}" class="nav-item {% if activeResource == resource.slug %}active{% endif %}">
|
|
84
54
|
<span class="nav-icon icon icon-16">
|
|
85
|
-
<svg viewBox="0 0 24 24"><use href="#ic-table"/></svg>
|
|
55
|
+
<svg viewBox="0 0 24 24"><use href="#ic-{{ resource.icon or 'table' }}"/></svg>
|
|
86
56
|
</span>
|
|
87
57
|
{{ resource.label }}
|
|
88
58
|
</a>
|
|
59
|
+
{% endif %}
|
|
89
60
|
{% endfor %}
|
|
90
61
|
</div>
|
|
62
|
+
|
|
63
|
+
{% set hasAuth = false %}
|
|
64
|
+
{% for resource in resources %}{% if resource.category == 'auth' %}{% set hasAuth = true %}{% endif %}{% endfor %}
|
|
65
|
+
{% if hasAuth %}
|
|
66
|
+
<div class="nav-section">
|
|
67
|
+
<div class="nav-label">Auth</div>
|
|
68
|
+
{% for resource in resources %}
|
|
69
|
+
{% if resource.category == 'auth' %}
|
|
70
|
+
<a href="{{ adminPrefix }}/{{ resource.slug }}" class="nav-item {% if activeResource == resource.slug %}active{% endif %}">
|
|
71
|
+
<span class="nav-icon icon icon-16">
|
|
72
|
+
<svg viewBox="0 0 24 24"><use href="#ic-{{ resource.icon or 'user' }}"/></svg>
|
|
73
|
+
</span>
|
|
74
|
+
{{ resource.label }}
|
|
75
|
+
</a>
|
|
76
|
+
{% endif %}
|
|
77
|
+
{% endfor %}
|
|
78
|
+
</div>
|
|
79
|
+
{% endif %}
|
|
91
80
|
{% endif %}
|
|
92
81
|
|
|
93
82
|
<div class="sidebar-footer">
|