webspresso 0.0.88 → 0.0.90

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.
@@ -28,6 +28,7 @@ const { getJsonColumns, serializeJsonFields, deserializeJsonFields } = require('
28
28
  function createRepository(model, knex, initialContext, cacheLayer = null) {
29
29
  const scopeContext = initialContext || createScopeContext();
30
30
  const jsonColumns = getJsonColumns(model);
31
+ const hasJson = jsonColumns.size > 0;
31
32
 
32
33
  /**
33
34
  * Get base query builder
@@ -47,12 +48,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
47
48
  */
48
49
  async function findById(id, options = {}, emitEvents = true) {
49
50
  const { with: withs = [], select } = options;
50
- const ctx = createEventContext(model.name, 'find');
51
- const query = { [model.primaryKey]: id };
51
+ const shouldEmit = emitEvents && (
52
+ ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
53
+ ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
54
+ );
55
+ const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
52
56
 
53
57
  async function loadFromDb() {
54
- if (emitEvents) {
55
- await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, query, ctx);
58
+ if (shouldEmit) {
59
+ await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, { [model.primaryKey]: id }, ctx);
56
60
  if (ctx.isCancelled) {
57
61
  throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
58
62
  }
@@ -70,13 +74,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
70
74
  return null;
71
75
  }
72
76
 
73
- deserializeJsonFields(record, jsonColumns);
77
+ if (hasJson) {
78
+ deserializeJsonFields(record, jsonColumns);
79
+ }
74
80
 
75
81
  if (withs.length > 0) {
76
82
  await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
77
83
  }
78
84
 
79
- if (emitEvents) {
85
+ if (shouldEmit) {
80
86
  ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
81
87
  }
82
88
 
@@ -113,7 +119,11 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
113
119
  */
114
120
  async function findOne(conditions, options = {}) {
115
121
  const { with: withs = [], select } = options;
116
- const ctx = createEventContext(model.name, 'find');
122
+ const shouldEmit = (
123
+ ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
124
+ ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
125
+ );
126
+ const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
117
127
 
118
128
  const condKeys = Object.keys(conditions);
119
129
  const isPkOnly =
@@ -122,9 +132,11 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
122
132
  condKeys[0] === model.primaryKey;
123
133
 
124
134
  async function loadFromDb() {
125
- await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, conditions, ctx);
126
- if (ctx.isCancelled) {
127
- throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
135
+ if (shouldEmit) {
136
+ await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, conditions, ctx);
137
+ if (ctx.isCancelled) {
138
+ throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
139
+ }
128
140
  }
129
141
 
130
142
  let qb = baseQuery();
@@ -143,13 +155,17 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
143
155
  return null;
144
156
  }
145
157
 
146
- deserializeJsonFields(record, jsonColumns);
158
+ if (hasJson) {
159
+ deserializeJsonFields(record, jsonColumns);
160
+ }
147
161
 
148
162
  if (withs.length > 0) {
149
163
  await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
150
164
  }
151
165
 
152
- ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
166
+ if (shouldEmit) {
167
+ ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
168
+ }
153
169
 
154
170
  return record;
155
171
  }
@@ -183,12 +199,18 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
183
199
  */
184
200
  async function findAll(options = {}) {
185
201
  const { with: withs = [], select } = options;
186
- const ctx = createEventContext(model.name, 'find');
202
+ const shouldEmit = (
203
+ ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
204
+ ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
205
+ );
206
+ const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
187
207
 
188
208
  async function loadFromDb() {
189
- await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, {}, ctx);
190
- if (ctx.isCancelled) {
191
- throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
209
+ if (shouldEmit) {
210
+ await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, {}, ctx);
211
+ if (ctx.isCancelled) {
212
+ throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
213
+ }
192
214
  }
193
215
 
194
216
  let qb = baseQuery();
@@ -199,16 +221,22 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
199
221
 
200
222
  const records = await qb;
201
223
 
202
- for (const record of records) {
203
- deserializeJsonFields(record, jsonColumns);
224
+ if (hasJson) {
225
+ const len = records.length;
226
+ for (let i = 0; i < len; i++) {
227
+ deserializeJsonFields(records[i], jsonColumns);
228
+ }
204
229
  }
205
230
 
206
231
  if (withs.length > 0 && records.length > 0) {
207
232
  await loadRelations(records, ensureArray(withs), model, knex, scopeContext);
208
233
  }
209
234
 
210
- for (const record of records) {
211
- ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
235
+ if (shouldEmit) {
236
+ const len = records.length;
237
+ for (let i = 0; i < len; i++) {
238
+ ModelEvents.emit(model.name, Hooks.AFTER_FIND, records[i], ctx);
239
+ }
212
240
  }
213
241
 
214
242
  return records;
@@ -261,7 +289,9 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
261
289
  let insertData = applyInsertModifiers(validated, scopeContext, model);
262
290
 
263
291
  // Serialize JSON fields
264
- insertData = serializeJsonFields(insertData, jsonColumns);
292
+ if (hasJson) {
293
+ insertData = serializeJsonFields(insertData, jsonColumns);
294
+ }
265
295
 
266
296
  // Insert and return the record
267
297
  const [id] = await knex(model.table).insert(insertData).returning(model.primaryKey);
@@ -341,7 +371,9 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
341
371
  delete updateData[model.primaryKey];
342
372
 
343
373
  // Serialize JSON fields
344
- updateData = serializeJsonFields(updateData, jsonColumns);
374
+ if (hasJson) {
375
+ updateData = serializeJsonFields(updateData, jsonColumns);
376
+ }
345
377
 
346
378
  // Update the record
347
379
  const updated = await baseQuery()
@@ -5,6 +5,9 @@
5
5
  */
6
6
 
7
7
  const METADATA_MARKER = '__wdb__';
8
+ const metaWeakMap = new WeakMap();
9
+ const metaStringCache = new Map();
10
+ const schemaColumnsWeakMap = new WeakMap();
8
11
 
9
12
  /**
10
13
  * Encode column metadata into a JSON string for Zod .describe()
@@ -22,14 +25,25 @@ function encodeColumnMeta(meta) {
22
25
  */
23
26
  function decodeColumnMeta(description) {
24
27
  if (!description) return null;
28
+ const cached = metaStringCache.get(description);
29
+ if (cached !== undefined) return cached;
30
+
25
31
  try {
26
32
  const parsed = JSON.parse(description);
27
33
  if (parsed && parsed[METADATA_MARKER]) {
28
- return parsed.meta;
34
+ const meta = parsed.meta;
35
+ if (metaStringCache.size < 500) {
36
+ metaStringCache.set(description, meta);
37
+ }
38
+ return meta;
29
39
  }
30
40
  } catch {
31
41
  // Not our metadata, ignore
32
42
  }
43
+
44
+ if (metaStringCache.size < 500) {
45
+ metaStringCache.set(description, null);
46
+ }
33
47
  return null;
34
48
  }
35
49
 
@@ -39,7 +53,7 @@ function decodeColumnMeta(description) {
39
53
  * @returns {boolean}
40
54
  */
41
55
  function hasColumnMeta(schema) {
42
- return decodeColumnMeta(schema.description) !== null;
56
+ return getColumnMeta(schema) !== null;
43
57
  }
44
58
 
45
59
  /**
@@ -48,6 +62,10 @@ function hasColumnMeta(schema) {
48
62
  * @returns {import('./types').ColumnMeta|null}
49
63
  */
50
64
  function getColumnMeta(schema) {
65
+ if (!schema) return null;
66
+ const direct = metaWeakMap.get(schema);
67
+ if (direct !== undefined) return direct;
68
+ if (schema._wdbMeta) return schema._wdbMeta;
51
69
  return decodeColumnMeta(schema.description);
52
70
  }
53
71
 
@@ -253,6 +271,7 @@ class SchemaBuilder {
253
271
 
254
272
  // Apply metadata to schema
255
273
  this._schema = this._schema.describe(encodeColumnMeta(finalMeta));
274
+ metaWeakMap.set(this._schema, finalMeta);
256
275
  this._finalized = true;
257
276
  return this._schema;
258
277
  }
@@ -770,6 +789,15 @@ function createSchemaHelpers(z) {
770
789
  * @returns {Map<string, import('./types').ColumnMeta>}
771
790
  */
772
791
  function extractColumnsFromSchema(schema) {
792
+ if (!schema || typeof schema !== 'object' || !schema.shape) {
793
+ return new Map();
794
+ }
795
+
796
+ const cached = schemaColumnsWeakMap.get(schema);
797
+ if (cached !== undefined) {
798
+ return cached;
799
+ }
800
+
773
801
  const columns = new Map();
774
802
  const shape = schema.shape;
775
803
 
@@ -806,6 +834,7 @@ function extractColumnsFromSchema(schema) {
806
834
  }
807
835
  }
808
836
 
837
+ schemaColumnsWeakMap.set(schema, columns);
809
838
  return columns;
810
839
  }
811
840
 
@@ -185,11 +185,12 @@ function applyUpdateModifiers(data, model) {
185
185
  * Create default scope context
186
186
  * @returns {import('./types').ScopeContext}
187
187
  */
188
- function createScopeContext() {
188
+ function createScopeContext(overrides = {}) {
189
189
  return {
190
190
  tenantId: undefined,
191
191
  withTrashed: false,
192
192
  onlyTrashed: false,
193
+ ...overrides,
193
194
  };
194
195
  }
195
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.88",
3
+ "version": "0.0.90",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -171,6 +171,16 @@ var routes = {
171
171
  return ProfilePage;
172
172
  }
173
173
  },
174
+ '/admins': {
175
+ onmatch: async () => {
176
+ const isAuth = await checkAuth();
177
+ if (!isAuth) {
178
+ redirectToLogin();
179
+ return;
180
+ }
181
+ return AdminUsersPage;
182
+ }
183
+ },
174
184
  '/users/sessions': {
175
185
  onmatch: async () => {
176
186
  if (!(await guardUserManagementRoutes())) return;
@@ -16,52 +16,59 @@ const Pagination = {
16
16
  pages.push(i);
17
17
  }
18
18
 
19
- return m('.flex.items-center.justify-between.px-4.py-3.bg-white dark:bg-slate-800.border-t', [
20
- m('.text-sm.text-gray-700', [
19
+ return m('.flex.flex-col.sm:flex-row.items-center.justify-between.gap-3.px-4.py-3.bg-white.dark:bg-slate-800.border-t.border-gray-200.dark:border-slate-700', [
20
+ m('.text-xs.sm:text-sm.text-gray-600.dark:text-slate-400.text-center.sm:text-left', [
21
21
  'Showing ',
22
- m('span.font-medium', ((page - 1) * perPage) + 1),
22
+ m('span.font-semibold.text-gray-900.dark:text-slate-200', ((page - 1) * perPage) + 1),
23
23
  ' to ',
24
- m('span.font-medium', Math.min(page * perPage, total)),
24
+ m('span.font-semibold.text-gray-900.dark:text-slate-200', Math.min(page * perPage, total)),
25
25
  ' of ',
26
- m('span.font-medium', total),
26
+ m('span.font-semibold.text-gray-900.dark:text-slate-200', total),
27
27
  ' results',
28
28
  ]),
29
- m('nav.flex.items-center.space-x-1', [
29
+ m('nav.flex.items-center.gap-1', [
30
30
  // Previous button
31
- m('button.px-3.py-1.rounded.border.text-sm', {
31
+ m('button.px-3.py-1.5.rounded-lg.border.border-gray-200.dark:border-slate-600.text-xs.sm:text-sm.font-medium.transition-colors', {
32
32
  disabled: page <= 1,
33
- class: page <= 1 ? 'text-gray-300 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700',
33
+ class: page <= 1
34
+ ? 'text-gray-300 dark:text-slate-600 cursor-not-allowed bg-gray-50 dark:bg-slate-900/50'
35
+ : 'text-gray-700 dark:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 bg-white dark:bg-slate-800',
34
36
  onclick: () => page > 1 && onPageChange(page - 1),
35
37
  }, '← Prev'),
38
+
39
+ // Mobile page indicator
40
+ m('span.sm:hidden.px-2.text-xs.font-medium.text-gray-600.dark:text-slate-400', `${page} / ${totalPages}`),
36
41
 
37
- // Page numbers
42
+ // Page numbers (desktop only)
38
43
  start > 1 ? [
39
- m('button.px-3.py-1.rounded.text-sm.text-gray-700 dark:text-slate-300.hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700', {
44
+ m('button.hidden.sm:inline-flex.px-3.py-1.5.rounded-lg.text-xs.sm:text-sm.font-medium.text-gray-700.dark:text-slate-300.hover:bg-gray-100.dark:hover:bg-slate-700.transition-colors', {
40
45
  onclick: () => onPageChange(1),
41
46
  }, '1'),
42
- start > 2 ? m('span.px-2.text-gray-400', '...') : null,
47
+ start > 2 ? m('span.hidden.sm:inline-flex.px-1.text-gray-400.dark:text-slate-500', '...') : null,
43
48
  ] : null,
44
49
 
45
50
  ...pages.map(p =>
46
- m('button.px-3.py-1.rounded.text-sm', {
51
+ m('button.hidden.sm:inline-flex.px-3.py-1.5.rounded-lg.text-xs.sm:text-sm.font-medium.transition-colors', {
47
52
  class: p === page
48
- ? 'bg-blue-600 text-white'
49
- : 'text-gray-700 hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700',
53
+ ? 'bg-blue-600 dark:bg-blue-500 text-white shadow-sm'
54
+ : 'text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700',
50
55
  onclick: () => onPageChange(p),
51
56
  }, p)
52
57
  ),
53
58
 
54
59
  end < totalPages ? [
55
- end < totalPages - 1 ? m('span.px-2.text-gray-400', '...') : null,
56
- m('button.px-3.py-1.rounded.text-sm.text-gray-700 dark:text-slate-300.hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700', {
60
+ end < totalPages - 1 ? m('span.hidden.sm:inline-flex.px-1.text-gray-400.dark:text-slate-500', '...') : null,
61
+ m('button.hidden.sm:inline-flex.px-3.py-1.5.rounded-lg.text-xs.sm:text-sm.font-medium.text-gray-700.dark:text-slate-300.hover:bg-gray-100.dark:hover:bg-slate-700.transition-colors', {
57
62
  onclick: () => onPageChange(totalPages),
58
63
  }, totalPages),
59
64
  ] : null,
60
65
 
61
66
  // Next button
62
- m('button.px-3.py-1.rounded.border.text-sm', {
67
+ m('button.px-3.py-1.5.rounded-lg.border.border-gray-200.dark:border-slate-600.text-xs.sm:text-sm.font-medium.transition-colors', {
63
68
  disabled: page >= totalPages,
64
- class: page >= totalPages ? 'text-gray-300 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700',
69
+ class: page >= totalPages
70
+ ? 'text-gray-300 dark:text-slate-600 cursor-not-allowed bg-gray-50 dark:bg-slate-900/50'
71
+ : 'text-gray-700 dark:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 bg-white dark:bg-slate-800',
65
72
  onclick: () => page < totalPages && onPageChange(page + 1),
66
73
  }, 'Next →'),
67
74
  ]),
@@ -61,7 +61,7 @@ const RecordList = {
61
61
 
62
62
  return m(Layout, { breadcrumbs }, [
63
63
  // Header
64
- m('.flex.items-center.justify-between.mb-4', [
64
+ m('.flex.flex-col.sm:flex-row.sm:items-center.sm:justify-between.gap-3.mb-4', [
65
65
  m('.flex.items-center.gap-3', [
66
66
  m('h2.text-2xl.font-bold', modelMeta?.label || modelName),
67
67
  m(RefreshIconButton, {
@@ -76,16 +76,16 @@ const RecordList = {
76
76
  });
77
77
  },
78
78
  }),
79
- modelMeta?.softDelete ? m('.flex.rounded-lg.border.border-gray-200 dark:border-slate-600.p-0.5', [
79
+ modelMeta?.softDelete ? m('.flex.rounded-lg.border.border-gray-200.dark:border-slate-600.p-0.5', [
80
80
  m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
81
- class: !state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900 dark:hover:text-slate-100 dark:hover:text-slate-100',
81
+ class: !state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900.dark:hover:text-slate-100',
82
82
  onclick: () => {
83
83
  state.trashedView = false;
84
84
  loadRecords(modelName, 1);
85
85
  },
86
86
  }, 'Active'),
87
87
  m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
88
- class: state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900 dark:hover:text-slate-100 dark:hover:text-slate-100',
88
+ class: state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900.dark:hover:text-slate-100',
89
89
  onclick: () => {
90
90
  state.trashedView = true;
91
91
  loadRecords(modelName, 1);
@@ -232,29 +232,29 @@ const RecordList = {
232
232
  ])
233
233
  : m('.bg-white dark:bg-slate-800.rounded-lg.shadow-sm.border.border-gray-200 dark:border-slate-600.overflow-hidden', [
234
234
  // Bulk Actions Toolbar (shown when items selected)
235
- (state.selectedRecords && state.selectedRecords.size > 0) || state.selectAllMode ? m('.bg-indigo-50.border-b.border-indigo-100.px-4.py-3.flex.items-center.justify-between', [
236
- m('.flex.items-center.gap-3', [
237
- m('span.text-sm.text-indigo-700.font-medium',
235
+ (state.selectedRecords && state.selectedRecords.size > 0) || state.selectAllMode ? m('.bg-indigo-50.dark:bg-indigo-950/40.border-b.border-indigo-100.dark:border-indigo-900/50.px-4.py-3.flex.flex-col.sm:flex-row.sm:items-center.sm:justify-between.gap-3', [
236
+ m('.flex.flex-wrap.items-center.gap-2', [
237
+ m('span.text-sm.text-indigo-700.dark:text-indigo-300.font-medium',
238
238
  state.selectAllMode
239
239
  ? 'All ' + state.pagination.total + ' records selected'
240
240
  : state.selectedRecords.size + ' record' + (state.selectedRecords.size > 1 ? 's' : '') + ' selected'
241
241
  ),
242
242
  // Show "Select all X records" option when current page is fully selected
243
- !state.selectAllMode && state.selectedRecords.size === state.records.length && state.pagination.total > state.records.length && m('button.text-sm.text-indigo-600.hover:text-indigo-800.underline.font-medium', {
243
+ !state.selectAllMode && state.selectedRecords.size === state.records.length && state.pagination.total > state.records.length && m('button.text-sm.text-indigo-600.dark:text-indigo-400.hover:text-indigo-800.underline.font-medium', {
244
244
  onclick: () => {
245
245
  state.selectAllMode = true;
246
246
  m.redraw();
247
247
  },
248
248
  }, 'Select all ' + state.pagination.total + ' records'),
249
249
  // Show "Select only this page" when in selectAllMode
250
- state.selectAllMode && m('button.text-sm.text-indigo-600.hover:text-indigo-800.underline', {
250
+ state.selectAllMode && m('button.text-sm.text-indigo-600.dark:text-indigo-400.hover:text-indigo-800.underline', {
251
251
  onclick: () => {
252
252
  state.selectAllMode = false;
253
253
  m.redraw();
254
254
  },
255
255
  }, 'Select only this page (' + state.selectedRecords.size + ')'),
256
256
  ]),
257
- m('.flex.items-center.gap-2', [
257
+ m('.flex.flex-wrap.items-center.gap-1.5', [
258
258
  state.trashedView && modelMeta?.softDelete
259
259
  ? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-green-600.bg-white dark:bg-slate-800.border.border-green-200.rounded.hover:bg-green-50.transition-colors', {
260
260
  disabled: state.bulkActionInProgress,
@@ -16,6 +16,7 @@ const { registerDefaultBulkActions, generateBulkActionsComponent } = require('./
16
16
  const { registerDefaultPages, createCustomPageApiHandlers, generateCustomPageComponent } = require('./modules/custom-pages');
17
17
  const { registerModelMenuItems, registerSystemMenuItems, generateMenuComponent } = require('./modules/menu');
18
18
  const { generateProfileComponent } = require('./modules/profile');
19
+ const { registerAdminUsersManagement, createAdminUsersApiHandlers, generateAdminUsersComponent } = require('./modules/admin-users');
19
20
  const { registerModule, registerPageDir } = require('./core/admin-module');
20
21
 
21
22
 
@@ -44,11 +45,14 @@ function adminPanelPlugin(options = {}) {
44
45
  db,
45
46
  auth,
46
47
  userManagement: userMgmtConfig,
48
+ adminUsers: adminUsersOption = true,
47
49
  configure,
48
50
  uploadUrl: uploadUrlOption,
49
51
  richTextSanitize = true,
50
52
  } = options;
51
53
 
54
+ const adminUsersConfig = typeof adminUsersOption === 'object' ? adminUsersOption : { enabled: adminUsersOption !== false };
55
+
52
56
  // Validate required options
53
57
  if (!db) {
54
58
  throw new Error('Admin panel plugin requires a database instance. Pass `db` in options.');
@@ -152,13 +156,20 @@ function adminPanelPlugin(options = {}) {
152
156
  // Session must be registered before file routes so SSR pages can read adminUser.
153
157
  if (enabled && ctx.app && !ctx.app._webspressoSessionInitialized) {
154
158
  const secret = sessionSecret || process.env.SESSION_SECRET || 'webspresso-admin-secret-change-in-production';
159
+ const secureMode =
160
+ process.env.NODE_ENV === 'production' ||
161
+ process.env.COOKIE_SECURE === 'true' ||
162
+ /^https:/i.test(String(process.env.BASE_URL || '').trim())
163
+ ? true
164
+ : 'auto';
155
165
  ctx.app.use(session({
156
166
  secret,
157
167
  resave: false,
158
168
  saveUninitialized: false,
159
169
  cookie: {
160
- secure: process.env.NODE_ENV === 'production',
170
+ secure: secureMode,
161
171
  httpOnly: true,
172
+ sameSite: 'lax',
162
173
  maxAge: 24 * 60 * 60 * 1000, // 24 hours
163
174
  },
164
175
  }));
@@ -227,6 +238,14 @@ function adminPanelPlugin(options = {}) {
227
238
  });
228
239
  }
229
240
 
241
+ // Register admin user management if enabled
242
+ if (adminUsersConfig.enabled !== false) {
243
+ registerAdminUsersManagement({
244
+ registry,
245
+ config: adminUsersConfig,
246
+ });
247
+ }
248
+
230
249
  // Create API handlers
231
250
  const apiHandlers = createApiHandlers({
232
251
  path: adminPath,
@@ -245,6 +264,16 @@ function adminPanelPlugin(options = {}) {
245
264
 
246
265
  const pageHandlers = createCustomPageApiHandlers({ registry, db });
247
266
 
267
+ // Admin users management API handlers
268
+ let adminUsersHandlers = null;
269
+ if (adminUsersConfig.enabled !== false) {
270
+ adminUsersHandlers = createAdminUsersApiHandlers({
271
+ db,
272
+ AdminUser,
273
+ hashPassword: (password, rounds) => bcrypt.hash(password, rounds),
274
+ });
275
+ }
276
+
248
277
  // User management API handlers
249
278
  let userHandlers = null;
250
279
  if (userMgmtConfig?.enabled) {
@@ -289,6 +318,15 @@ function adminPanelPlugin(options = {}) {
289
318
  ctx.addRoute('get', `${adminPath}/api/extensions/pages/:pageId/data`, requireAuth, pageHandlers.getPageData);
290
319
  ctx.addRoute('all', `${adminPath}/api/extensions/pages/:pageId/actions/:actionId`, requireAuth, pageHandlers.executePageAction);
291
320
 
321
+ // Admin users management API routes
322
+ if (adminUsersHandlers) {
323
+ ctx.addRoute('get', `${adminPath}/api/admins`, requireAuth, adminUsersHandlers.listAdmins);
324
+ ctx.addRoute('get', `${adminPath}/api/admins/:id`, requireAuth, adminUsersHandlers.getAdmin);
325
+ ctx.addRoute('post', `${adminPath}/api/admins`, requireAuth, adminUsersHandlers.createAdmin);
326
+ ctx.addRoute('put', `${adminPath}/api/admins/:id`, requireAuth, adminUsersHandlers.updateAdmin);
327
+ ctx.addRoute('delete', `${adminPath}/api/admins/:id`, requireAuth, adminUsersHandlers.deleteAdmin);
328
+ }
329
+
292
330
  // User management API routes
293
331
  if (userHandlers) {
294
332
  ctx.addRoute('get', `${adminPath}/api/users`, requireAuth, userHandlers.listUsers);
@@ -391,6 +429,7 @@ function generateAdminPanelHtml(adminPath, registry) {
391
429
  const bulkActionsComponent = generateBulkActionsComponent();
392
430
  const customPageComponent = generateCustomPageComponent();
393
431
  const profileComponent = generateProfileComponent();
432
+ const adminUsersComponent = generateAdminUsersComponent();
394
433
 
395
434
  const settings = registry.settings;
396
435
  const fieldRenderersCode = Array.from(registry.fieldRenderers.entries()).map(([type, r]) => {
@@ -473,6 +512,7 @@ function generateAdminPanelHtml(adminPath, registry) {
473
512
  ${bulkActionsComponent}
474
513
  ${customPageComponent}
475
514
  ${profileComponent}
515
+ ${adminUsersComponent}
476
516
 
477
517
  // Custom page components container
478
518
  window.__customPages = window.__customPages || {};