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.
@@ -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;
@@ -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) shown in sidebar and page title */
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 the ic- prefix e.g. 'users', 'file', 'tag' */
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 across (SQL LIKE) */
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 that appear on the form as read-only (shown as text, not input).
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 shown in list, detail, and form views.
89
- * Use AdminField.tab('Tab Name') to group fields into tabs on the form.
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 — build OR conditions across all searchable columns
170
+ // Search
120
171
  if (search && this.searchable.length) {
121
- const searchable = this.searchable; // capture for closure
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 support __ lookups (e.g. created_at__gte) as well as plain equality
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
- // Run count and data fetch in parallel
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
- * All fields after this marker belong to this tab until the next one.
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.tab('General'),
257
- * AdminField.text('name'),
258
- * AdminField.tab('Settings'),
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 tab(label) {
264
- const f = new AdminField('__tab__', 'tab');
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
- module.exports = { AdminResource, AdminField, AdminFilter };
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
- <div class="sidebar-version flex items-center gap-1">
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>