millas 0.2.5 → 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 +1 -1
- package/src/admin/Admin.js +241 -62
- package/src/admin/AdminAuth.js +281 -0
- package/src/admin/index.js +6 -1
- package/src/admin/resources/AdminResource.js +180 -29
- package/src/admin/views/layouts/base.njk +38 -1
- package/src/admin/views/pages/detail.njk +322 -0
- package/src/admin/views/pages/form.njk +571 -125
- package/src/admin/views/pages/list.njk +454 -0
- package/src/admin/views/pages/login.njk +354 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const bcrypt = require('bcryptjs');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AdminAuth
|
|
8
|
+
*
|
|
9
|
+
* Handles authentication for the Millas admin panel.
|
|
10
|
+
*
|
|
11
|
+
* Uses a signed, httpOnly cookie for sessions — no express-session
|
|
12
|
+
* or database required. The cookie payload is HMAC-signed with
|
|
13
|
+
* APP_KEY so it cannot be forged.
|
|
14
|
+
*
|
|
15
|
+
* Configuration (in Admin.configure or config/admin.js):
|
|
16
|
+
*
|
|
17
|
+
* Admin.configure({
|
|
18
|
+
* auth: {
|
|
19
|
+
* // Static user list — good for simple setups
|
|
20
|
+
* users: [
|
|
21
|
+
* { email: 'admin@example.com', password: 'plain-or-bcrypt-hash', name: 'Admin' },
|
|
22
|
+
* ],
|
|
23
|
+
*
|
|
24
|
+
* // OR: use a Model — any model with email + password fields
|
|
25
|
+
* model: UserModel,
|
|
26
|
+
*
|
|
27
|
+
* // Cookie settings
|
|
28
|
+
* cookieName: 'millas_admin', // default
|
|
29
|
+
* cookieMaxAge: 60 * 60 * 8, // 8 hours (seconds), default
|
|
30
|
+
* rememberAge: 60 * 60 * 24 * 30, // 30 days when "remember me" checked
|
|
31
|
+
*
|
|
32
|
+
* // Rate limiting (per IP)
|
|
33
|
+
* maxAttempts: 5,
|
|
34
|
+
* lockoutMinutes: 15,
|
|
35
|
+
* }
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* Disable auth entirely:
|
|
39
|
+
* Admin.configure({ auth: false });
|
|
40
|
+
*/
|
|
41
|
+
class AdminAuth {
|
|
42
|
+
constructor() {
|
|
43
|
+
this._config = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
configure(authConfig) {
|
|
47
|
+
if (authConfig === false) {
|
|
48
|
+
this._config = false;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this._config = {
|
|
53
|
+
users: [],
|
|
54
|
+
model: null,
|
|
55
|
+
cookieName: 'millas_admin',
|
|
56
|
+
cookieMaxAge: 60 * 60 * 8,
|
|
57
|
+
rememberAge: 60 * 60 * 24 * 30,
|
|
58
|
+
maxAttempts: 5,
|
|
59
|
+
lockoutMinutes: 15,
|
|
60
|
+
...authConfig,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Rate limit store: Map<ip, { count, lockedUntil }>
|
|
64
|
+
this._attempts = new Map();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Returns true if auth is enabled. */
|
|
68
|
+
get enabled() {
|
|
69
|
+
return this._config !== null && this._config !== false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Middleware ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Express middleware — allows the request through if the admin session
|
|
76
|
+
* cookie is valid. Redirects to the login page otherwise.
|
|
77
|
+
*/
|
|
78
|
+
middleware(prefix) {
|
|
79
|
+
return (req, res, next) => {
|
|
80
|
+
if (!this.enabled) return next();
|
|
81
|
+
|
|
82
|
+
const loginPath = `${prefix}/login`;
|
|
83
|
+
|
|
84
|
+
// Always allow login page and logout
|
|
85
|
+
if (req.path === '/login' || req.path === `${prefix}/login`) return next();
|
|
86
|
+
if (req.path === '/logout' || req.path === `${prefix}/logout`) return next();
|
|
87
|
+
|
|
88
|
+
const user = this._getSession(req);
|
|
89
|
+
if (!user) {
|
|
90
|
+
const returnTo = encodeURIComponent(req.originalUrl);
|
|
91
|
+
return res.redirect(`${loginPath}?next=${returnTo}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
req.adminUser = user;
|
|
95
|
+
next();
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Login ─────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Attempt to log in with email + password.
|
|
103
|
+
* Returns the user object on success, throws on failure.
|
|
104
|
+
*/
|
|
105
|
+
async login(req, res, { email, password, remember = false }) {
|
|
106
|
+
if (!this.enabled) return { email: 'admin', name: 'Admin' };
|
|
107
|
+
|
|
108
|
+
const ip = req.ip || req.connection?.remoteAddress || 'unknown';
|
|
109
|
+
this._checkRateLimit(ip);
|
|
110
|
+
|
|
111
|
+
const user = await this._findUser(email);
|
|
112
|
+
|
|
113
|
+
if (!user || !await this._checkPassword(password, user.password)) {
|
|
114
|
+
this._recordFailedAttempt(ip);
|
|
115
|
+
throw new Error('Invalid email or password.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this._clearAttempts(ip);
|
|
119
|
+
|
|
120
|
+
const maxAge = remember
|
|
121
|
+
? this._config.rememberAge
|
|
122
|
+
: this._config.cookieMaxAge;
|
|
123
|
+
|
|
124
|
+
this._setSession(res, { email: user.email, name: user.name || user.email }, maxAge);
|
|
125
|
+
|
|
126
|
+
return user;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Destroy the admin session cookie. */
|
|
130
|
+
logout(res) {
|
|
131
|
+
res.clearCookie(this._config.cookieName, { path: '/' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Flash (cookie-based) ─────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/** Store a flash message in a short-lived cookie. */
|
|
137
|
+
setFlash(res, type, message) {
|
|
138
|
+
const payload = JSON.stringify({ type, message });
|
|
139
|
+
res.cookie('millas_flash', Buffer.from(payload).toString('base64'), {
|
|
140
|
+
httpOnly: true,
|
|
141
|
+
maxAge: 10 * 1000, // 10 seconds — survives exactly one redirect
|
|
142
|
+
path: '/',
|
|
143
|
+
sameSite: 'lax',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read and clear the flash cookie. */
|
|
148
|
+
getFlash(req, res) {
|
|
149
|
+
const raw = this._parseCookies(req)['millas_flash'];
|
|
150
|
+
if (!raw) return {};
|
|
151
|
+
// Clear it immediately
|
|
152
|
+
res.clearCookie('millas_flash', { path: '/' });
|
|
153
|
+
try {
|
|
154
|
+
const { type, message } = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
|
|
155
|
+
return { [type]: message };
|
|
156
|
+
} catch { return {}; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Session internals ────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
_setSession(res, payload, maxAge) {
|
|
162
|
+
const name = this._config.cookieName;
|
|
163
|
+
const data = Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
164
|
+
const sig = this._sign(data);
|
|
165
|
+
const value = `${data}.${sig}`;
|
|
166
|
+
|
|
167
|
+
res.cookie(name, value, {
|
|
168
|
+
httpOnly: true,
|
|
169
|
+
maxAge: maxAge * 1000,
|
|
170
|
+
path: '/',
|
|
171
|
+
sameSite: 'lax',
|
|
172
|
+
// secure: true — uncomment in production behind HTTPS
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_getSession(req) {
|
|
177
|
+
const name = this._config.cookieName;
|
|
178
|
+
const raw = this._parseCookies(req)[name];
|
|
179
|
+
if (!raw) return null;
|
|
180
|
+
|
|
181
|
+
const dot = raw.lastIndexOf('.');
|
|
182
|
+
if (dot === -1) return null;
|
|
183
|
+
|
|
184
|
+
const data = raw.slice(0, dot);
|
|
185
|
+
const sig = raw.slice(dot + 1);
|
|
186
|
+
|
|
187
|
+
if (sig !== this._sign(data)) return null;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
return JSON.parse(Buffer.from(data, 'base64').toString('utf8'));
|
|
191
|
+
} catch { return null; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_sign(data) {
|
|
195
|
+
const secret = process.env.APP_KEY || 'millas-admin-secret-change-me';
|
|
196
|
+
return crypto.createHmac('sha256', secret).update(data).digest('hex').slice(0, 32);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_parseCookies(req) {
|
|
200
|
+
const header = req.headers.cookie || '';
|
|
201
|
+
const result = {};
|
|
202
|
+
for (const part of header.split(';')) {
|
|
203
|
+
const [k, ...v] = part.trim().split('=');
|
|
204
|
+
if (k) result[k.trim()] = decodeURIComponent(v.join('='));
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── User lookup ──────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
async _findUser(email) {
|
|
212
|
+
const cfg = this._config;
|
|
213
|
+
const normalised = (email || '').trim().toLowerCase();
|
|
214
|
+
|
|
215
|
+
// Model-based lookup
|
|
216
|
+
if (cfg.model) {
|
|
217
|
+
try {
|
|
218
|
+
return await cfg.model.findBy('email', normalised);
|
|
219
|
+
} catch { return null; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Static user list
|
|
223
|
+
if (cfg.users && cfg.users.length) {
|
|
224
|
+
return cfg.users.find(u =>
|
|
225
|
+
(u.email || '').trim().toLowerCase() === normalised
|
|
226
|
+
) || null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async _checkPassword(plain, hash) {
|
|
233
|
+
if (!plain || !hash) return false;
|
|
234
|
+
// Support both plain-text passwords (dev) and bcrypt hashes (prod)
|
|
235
|
+
if (hash.startsWith('$2')) {
|
|
236
|
+
return bcrypt.compare(String(plain), hash);
|
|
237
|
+
}
|
|
238
|
+
// Plain text comparison — warn in development
|
|
239
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
240
|
+
process.stderr.write(
|
|
241
|
+
'[millas admin] Warning: using plain-text password. Use a bcrypt hash in production.\n'
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return plain === hash;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Rate limiting ────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
_checkRateLimit(ip) {
|
|
250
|
+
const entry = this._attempts?.get(ip);
|
|
251
|
+
if (!entry) return;
|
|
252
|
+
|
|
253
|
+
if (entry.lockedUntil && Date.now() < entry.lockedUntil) {
|
|
254
|
+
const mins = Math.ceil((entry.lockedUntil - Date.now()) / 60000);
|
|
255
|
+
throw new Error(`Too many failed attempts. Try again in ${mins} minute${mins > 1 ? 's' : ''}.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (entry.lockedUntil && Date.now() >= entry.lockedUntil) {
|
|
259
|
+
this._attempts.delete(ip);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_recordFailedAttempt(ip) {
|
|
264
|
+
const entry = this._attempts?.get(ip) || { count: 0, lockedUntil: null };
|
|
265
|
+
entry.count++;
|
|
266
|
+
if (entry.count >= (this._config.maxAttempts || 5)) {
|
|
267
|
+
const mins = this._config.lockoutMinutes || 15;
|
|
268
|
+
entry.lockedUntil = Date.now() + mins * 60 * 1000;
|
|
269
|
+
}
|
|
270
|
+
this._attempts?.set(ip, entry);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
_clearAttempts(ip) {
|
|
274
|
+
this._attempts?.delete(ip);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Singleton
|
|
279
|
+
const adminAuth = new AdminAuth();
|
|
280
|
+
module.exports = adminAuth;
|
|
281
|
+
module.exports.AdminAuth = AdminAuth;
|
package/src/admin/index.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const Admin = require('./Admin');
|
|
4
|
+
const AdminAuth = require('./AdminAuth');
|
|
5
|
+
const ActivityLog = require('./ActivityLog');
|
|
4
6
|
const AdminServiceProvider = require('../providers/AdminServiceProvider');
|
|
5
|
-
const { AdminResource, AdminField, AdminFilter } = require('./resources/AdminResource');
|
|
7
|
+
const { AdminResource, AdminField, AdminFilter, AdminInline } = require('./resources/AdminResource');
|
|
6
8
|
|
|
7
9
|
module.exports = {
|
|
8
10
|
Admin,
|
|
11
|
+
AdminAuth,
|
|
12
|
+
ActivityLog,
|
|
9
13
|
AdminResource,
|
|
10
14
|
AdminField,
|
|
11
15
|
AdminFilter,
|
|
16
|
+
AdminInline,
|
|
12
17
|
AdminServiceProvider,
|
|
13
18
|
};
|
|
@@ -41,19 +41,19 @@ class AdminResource {
|
|
|
41
41
|
/** @type {typeof import('../orm/model/Model')} The Millas Model class */
|
|
42
42
|
static model = null;
|
|
43
43
|
|
|
44
|
-
/** Display name (plural)
|
|
44
|
+
/** Display name (plural) */
|
|
45
45
|
static label = null;
|
|
46
46
|
|
|
47
47
|
/** Singular label */
|
|
48
48
|
static labelSingular = null;
|
|
49
49
|
|
|
50
|
-
/** SVG icon id without
|
|
50
|
+
/** SVG icon id (without ic- prefix) */
|
|
51
51
|
static icon = 'table';
|
|
52
52
|
|
|
53
|
-
/** Records per page */
|
|
53
|
+
/** Records per page default */
|
|
54
54
|
static perPage = 20;
|
|
55
55
|
|
|
56
|
-
/** Columns to search
|
|
56
|
+
/** Columns to search (SQL LIKE) */
|
|
57
57
|
static searchable = [];
|
|
58
58
|
|
|
59
59
|
/** Columns users can click to sort */
|
|
@@ -72,12 +72,69 @@ class AdminResource {
|
|
|
72
72
|
static canView = true;
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* Fields
|
|
76
|
-
* Useful for system-managed fields like created_at, updated_at, uuid.
|
|
75
|
+
* Fields shown as read-only (text) on the edit form.
|
|
77
76
|
* @type {string[]}
|
|
78
77
|
*/
|
|
79
78
|
static readonlyFields = [];
|
|
80
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Columns in the list view that link to the detail page.
|
|
82
|
+
* Defaults to the first column if empty.
|
|
83
|
+
* @type {string[]}
|
|
84
|
+
*/
|
|
85
|
+
static listDisplayLinks = [];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Date field for year/month drill-down filter above the list.
|
|
89
|
+
* e.g. static dateHierarchy = 'created_at'
|
|
90
|
+
* @type {string|null}
|
|
91
|
+
*/
|
|
92
|
+
static dateHierarchy = null;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Auto-fill mappings: { targetField: sourceField }
|
|
96
|
+
* When the user types in sourceField, targetField is auto-filled (slugified).
|
|
97
|
+
* e.g. static prepopulatedFields = { slug: 'title' }
|
|
98
|
+
* @type {object}
|
|
99
|
+
*/
|
|
100
|
+
static prepopulatedFields = {};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Custom bulk actions shown in the bulk action bar when rows are selected.
|
|
104
|
+
* Each entry: { label, icon, handler: async (ids, model) => void }
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* static actions = [
|
|
108
|
+
* {
|
|
109
|
+
* label: 'Publish selected',
|
|
110
|
+
* icon: 'check',
|
|
111
|
+
* handler: async (ids, model) => {
|
|
112
|
+
* await model.bulkUpdate(ids.map(id => ({ id, published: true })));
|
|
113
|
+
* },
|
|
114
|
+
* },
|
|
115
|
+
* ];
|
|
116
|
+
*/
|
|
117
|
+
static actions = [];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Custom per-row actions shown in the action dropdown menu.
|
|
121
|
+
* Each entry: { label, icon, href: (row) => string }
|
|
122
|
+
* OR { label, icon, action: string } (POST to /admin/:resource/:id/:action)
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* static rowActions = [
|
|
126
|
+
* { label: 'Preview', icon: 'eye', href: (row) => `/posts/${row.slug}` },
|
|
127
|
+
* { label: 'Publish', icon: 'check', action: 'publish' },
|
|
128
|
+
* ];
|
|
129
|
+
*/
|
|
130
|
+
static rowActions = [];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Inline related resource classes shown on the detail/edit page.
|
|
134
|
+
* @type {typeof AdminInline[]}
|
|
135
|
+
*/
|
|
136
|
+
static inlines = [];
|
|
137
|
+
|
|
81
138
|
/** URL-safe slug used in routes */
|
|
82
139
|
static get slug() {
|
|
83
140
|
return (this.label || this.model?.name || 'resource')
|
|
@@ -85,9 +142,8 @@ class AdminResource {
|
|
|
85
142
|
}
|
|
86
143
|
|
|
87
144
|
/**
|
|
88
|
-
* Define the fields
|
|
89
|
-
* Use AdminField.tab(
|
|
90
|
-
* Must return an array of AdminField instances.
|
|
145
|
+
* Define the fields for list, detail, and form views.
|
|
146
|
+
* Use AdminField.tab() and AdminField.fieldset() for layout.
|
|
91
147
|
*/
|
|
92
148
|
static fields() {
|
|
93
149
|
if (!this.model?.fields) return [];
|
|
@@ -96,10 +152,7 @@ class AdminResource {
|
|
|
96
152
|
);
|
|
97
153
|
}
|
|
98
154
|
|
|
99
|
-
/**
|
|
100
|
-
* Define filter controls shown in the filter panel.
|
|
101
|
-
* Must return an array of AdminFilter instances.
|
|
102
|
-
*/
|
|
155
|
+
/** Define filter controls. */
|
|
103
156
|
static filters() {
|
|
104
157
|
return [];
|
|
105
158
|
}
|
|
@@ -108,18 +161,15 @@ class AdminResource {
|
|
|
108
161
|
* Override to customise how records are fetched.
|
|
109
162
|
* Receives { page, perPage, search, sort, order, filters }
|
|
110
163
|
*/
|
|
111
|
-
static async fetchList({ page = 1, perPage, search, sort = 'id', order = 'desc', filters = {} } = {}) {
|
|
164
|
+
static async fetchList({ page = 1, perPage, search, sort = 'id', order = 'desc', filters = {}, year, month } = {}) {
|
|
112
165
|
const limit = perPage || this.perPage;
|
|
113
166
|
const offset = (page - 1) * limit;
|
|
114
167
|
|
|
115
|
-
// Start from the public query() entry point — works regardless of
|
|
116
|
-
// whether the ORM changes have been applied or not.
|
|
117
168
|
let qb = this.model.query().orderBy(sort, order);
|
|
118
169
|
|
|
119
|
-
// Search
|
|
170
|
+
// Search
|
|
120
171
|
if (search && this.searchable.length) {
|
|
121
|
-
const searchable = this.searchable;
|
|
122
|
-
// Use raw knex where group via the underlying _query
|
|
172
|
+
const searchable = this.searchable;
|
|
123
173
|
qb._query = qb._query.where(function () {
|
|
124
174
|
for (const col of searchable) {
|
|
125
175
|
this.orWhere(col, 'like', `%${search}%`);
|
|
@@ -127,14 +177,19 @@ class AdminResource {
|
|
|
127
177
|
});
|
|
128
178
|
}
|
|
129
179
|
|
|
130
|
-
// Filters
|
|
180
|
+
// Filters (supports __ lookups)
|
|
131
181
|
for (const [key, value] of Object.entries(filters)) {
|
|
132
182
|
if (value !== '' && value !== null && value !== undefined) {
|
|
133
183
|
qb.where(key, value);
|
|
134
184
|
}
|
|
135
185
|
}
|
|
136
186
|
|
|
137
|
-
//
|
|
187
|
+
// Date hierarchy drill-down
|
|
188
|
+
if (this.dateHierarchy) {
|
|
189
|
+
if (year) qb.where(`${this.dateHierarchy}__year`, Number(year));
|
|
190
|
+
if (month) qb.where(`${this.dateHierarchy}__month`, Number(month));
|
|
191
|
+
}
|
|
192
|
+
|
|
138
193
|
const [rows, total] = await Promise.all([
|
|
139
194
|
qb._query.clone().limit(limit).offset(offset),
|
|
140
195
|
qb._query.clone().clearSelect().count('* as count').first()
|
|
@@ -249,19 +304,30 @@ class AdminField {
|
|
|
249
304
|
|
|
250
305
|
/**
|
|
251
306
|
* Tab separator — splits the form into named tabs.
|
|
252
|
-
|
|
307
|
+
*/
|
|
308
|
+
static tab(label) {
|
|
309
|
+
const f = new AdminField('__tab__', 'tab');
|
|
310
|
+
f._label = label;
|
|
311
|
+
return f;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Fieldset separator — visually groups fields within a tab/form section.
|
|
316
|
+
* Unlike tabs, fieldsets don't switch panels — they just add a heading.
|
|
253
317
|
*
|
|
254
318
|
* static fields() {
|
|
255
319
|
* return [
|
|
256
|
-
* AdminField.
|
|
257
|
-
* AdminField.text('name'),
|
|
258
|
-
* AdminField.
|
|
320
|
+
* AdminField.fieldset('Personal Info'),
|
|
321
|
+
* AdminField.text('name').required(),
|
|
322
|
+
* AdminField.email('email').required(),
|
|
323
|
+
* AdminField.fieldset('Account Settings'),
|
|
324
|
+
* AdminField.select('role', ['admin','user']),
|
|
259
325
|
* AdminField.boolean('active'),
|
|
260
326
|
* ];
|
|
261
327
|
* }
|
|
262
328
|
*/
|
|
263
|
-
static
|
|
264
|
-
const f = new AdminField('
|
|
329
|
+
static fieldset(label) {
|
|
330
|
+
const f = new AdminField('__fieldset__', 'fieldset');
|
|
265
331
|
f._label = label;
|
|
266
332
|
return f;
|
|
267
333
|
}
|
|
@@ -284,8 +350,13 @@ class AdminField {
|
|
|
284
350
|
help(h) { this._help = h; return this; }
|
|
285
351
|
min(n) { this._min = n; return this; }
|
|
286
352
|
max(n) { this._max = n; return this; }
|
|
287
|
-
/** Assign this field to a named tab (alternative to using tab() separators). */
|
|
288
353
|
inTab(name) { this._tab = name; return this; }
|
|
354
|
+
/** Make this column link to the detail page in the list view. */
|
|
355
|
+
link() { this._isLink = true; return this; }
|
|
356
|
+
/** Auto-fill this field by slugifying another field as the user types.
|
|
357
|
+
* e.g. AdminField.text('slug').prepopulate('title')
|
|
358
|
+
*/
|
|
359
|
+
prepopulate(src) { this._prepopulate = src; return this; }
|
|
289
360
|
|
|
290
361
|
// ─── Serialise ─────────────────────────────────────────────────────────────
|
|
291
362
|
|
|
@@ -308,6 +379,8 @@ class AdminField {
|
|
|
308
379
|
span: this._span,
|
|
309
380
|
min: this._min,
|
|
310
381
|
max: this._max,
|
|
382
|
+
isLink: this._isLink || false,
|
|
383
|
+
prepopulate: this._prepopulate || null,
|
|
311
384
|
};
|
|
312
385
|
}
|
|
313
386
|
|
|
@@ -372,4 +445,82 @@ class AdminFilter {
|
|
|
372
445
|
}
|
|
373
446
|
}
|
|
374
447
|
|
|
375
|
-
|
|
448
|
+
// ── AdminInline ───────────────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* AdminInline
|
|
452
|
+
*
|
|
453
|
+
* Displays related records inline on the detail/edit page of a parent resource.
|
|
454
|
+
* Similar to Django's TabularInline / StackedInline.
|
|
455
|
+
*
|
|
456
|
+
* Usage in a parent resource:
|
|
457
|
+
*
|
|
458
|
+
* class PostResource extends AdminResource {
|
|
459
|
+
* static inlines = [
|
|
460
|
+
* new AdminInline({
|
|
461
|
+
* model: Comment,
|
|
462
|
+
* label: 'Comments',
|
|
463
|
+
* foreignKey: 'post_id',
|
|
464
|
+
* fields: ['id', 'author', 'body', 'created_at'],
|
|
465
|
+
* canCreate: true,
|
|
466
|
+
* canDelete: true,
|
|
467
|
+
* perPage: 10,
|
|
468
|
+
* }),
|
|
469
|
+
* ];
|
|
470
|
+
* }
|
|
471
|
+
*/
|
|
472
|
+
class AdminInline {
|
|
473
|
+
/**
|
|
474
|
+
* @param {object} options
|
|
475
|
+
* @param {class} options.model — Millas Model class
|
|
476
|
+
* @param {string} options.label — display label (plural)
|
|
477
|
+
* @param {string} options.foreignKey — FK column on the related table
|
|
478
|
+
* @param {string[]} [options.fields] — which columns to show (default: all)
|
|
479
|
+
* @param {boolean} [options.canCreate] — show add row button (default: false)
|
|
480
|
+
* @param {boolean} [options.canDelete] — show delete button (default: false)
|
|
481
|
+
* @param {number} [options.perPage] — max rows shown (default: 10)
|
|
482
|
+
*/
|
|
483
|
+
constructor({ model, label, foreignKey, fields = [], canCreate = false, canDelete = false, perPage = 10 }) {
|
|
484
|
+
this.model = model;
|
|
485
|
+
this.label = label || (model?.name ? model.name + 's' : 'Related');
|
|
486
|
+
this.foreignKey = foreignKey;
|
|
487
|
+
this.fields = fields;
|
|
488
|
+
this.canCreate = canCreate;
|
|
489
|
+
this.canDelete = canDelete;
|
|
490
|
+
this.perPage = perPage;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Fetch related rows for a given parent id. */
|
|
494
|
+
async fetchRows(parentId) {
|
|
495
|
+
if (!this.model || !this.foreignKey) return [];
|
|
496
|
+
try {
|
|
497
|
+
const rows = await this.model.query()
|
|
498
|
+
.where(this.foreignKey, parentId)
|
|
499
|
+
.limit(this.perPage)
|
|
500
|
+
.get();
|
|
501
|
+
return rows.map(r => r.toJSON ? r.toJSON() : r);
|
|
502
|
+
} catch { return []; }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Serialise to plain object for template rendering. */
|
|
506
|
+
toJSON() {
|
|
507
|
+
const modelFields = this.model?.fields || {};
|
|
508
|
+
const displayFields = this.fields.length
|
|
509
|
+
? this.fields
|
|
510
|
+
: Object.keys(modelFields).slice(0, 6);
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
label: this.label,
|
|
514
|
+
foreignKey: this.foreignKey,
|
|
515
|
+
canCreate: this.canCreate,
|
|
516
|
+
canDelete: this.canDelete,
|
|
517
|
+
fields: displayFields.map(name => ({
|
|
518
|
+
name,
|
|
519
|
+
label: name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
|
|
520
|
+
type: modelFields[name]?.type || 'text',
|
|
521
|
+
})),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
module.exports = { AdminResource, AdminField, AdminFilter, AdminInline };
|
|
@@ -200,6 +200,27 @@
|
|
|
200
200
|
color: var(--text-xmuted);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/* ── User row ── */
|
|
204
|
+
.user-row {
|
|
205
|
+
display: flex; align-items: center; gap: 9px;
|
|
206
|
+
}
|
|
207
|
+
.user-avatar {
|
|
208
|
+
width: 30px; height: 30px; border-radius: 8px;
|
|
209
|
+
background: var(--primary-dim); color: var(--primary);
|
|
210
|
+
display: flex; align-items: center; justify-content: center;
|
|
211
|
+
font-size: 13px; font-weight: 700; flex-shrink: 0;
|
|
212
|
+
}
|
|
213
|
+
.user-info { flex: 1; min-width: 0; }
|
|
214
|
+
.user-name { font-size: 12.5px; font-weight: 600; color: var(--text-soft); }
|
|
215
|
+
.user-email { font-size: 11px; color: var(--text-muted); }
|
|
216
|
+
.logout-btn {
|
|
217
|
+
flex-shrink: 0; padding: 5px;
|
|
218
|
+
border-radius: 5px; color: var(--text-muted);
|
|
219
|
+
display: flex; align-items: center;
|
|
220
|
+
transition: background .1s, color .1s;
|
|
221
|
+
}
|
|
222
|
+
.logout-btn:hover { background: var(--surface3); color: var(--danger); }
|
|
223
|
+
|
|
203
224
|
/* ════════════════════════════════════════
|
|
204
225
|
MAIN AREA
|
|
205
226
|
════════════════════════════════════════ */
|
|
@@ -933,7 +954,23 @@
|
|
|
933
954
|
{% endif %}
|
|
934
955
|
|
|
935
956
|
<div class="sidebar-footer">
|
|
936
|
-
|
|
957
|
+
{% if authEnabled and adminUser %}
|
|
958
|
+
<div class="user-row">
|
|
959
|
+
<div class="user-avatar">{{ (adminUser.name or adminUser.email or 'A')[0] | upper }}</div>
|
|
960
|
+
<div class="user-info">
|
|
961
|
+
<div class="user-name truncate">{{ adminUser.name or adminUser.email }}</div>
|
|
962
|
+
{% if adminUser.name %}<div class="user-email truncate">{{ adminUser.email }}</div>{% endif %}
|
|
963
|
+
</div>
|
|
964
|
+
<a href="{{ adminPrefix }}/logout" class="logout-btn" title="Sign out">
|
|
965
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
966
|
+
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
|
|
967
|
+
<polyline points="16 17 21 12 16 7"/>
|
|
968
|
+
<line x1="21" y1="12" x2="9" y2="12"/>
|
|
969
|
+
</svg>
|
|
970
|
+
</a>
|
|
971
|
+
</div>
|
|
972
|
+
{% endif %}
|
|
973
|
+
<div class="sidebar-version flex items-center gap-1" style="{% if authEnabled and adminUser %}margin-top:10px;padding-top:10px;border-top:1px solid var(--border-soft);{% endif %}">
|
|
937
974
|
<span class="icon icon-12" style="color:var(--text-xmuted)"><svg viewBox="0 0 24 24"><use href="#ic-activity"/></svg></span>
|
|
938
975
|
Millas v0.1.2
|
|
939
976
|
</div>
|