webspresso 0.0.89 → 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.
package/README.md CHANGED
@@ -528,24 +528,39 @@ const { app } = createApp({
528
528
  pagesDir: './pages',
529
529
  viewsDir: './views',
530
530
  errorPages: {
531
- // Option 1: Custom handler function
532
- notFound: (req, res) => {
533
- res.render('errors/404.njk', { url: req.url });
534
- },
531
+ // 404 Not Found: Template path (relative to viewsDir) or custom handler fn
532
+ notFound: '404.njk', // or 'errors/404.njk', or (req, res, ctx) => res.render('404.njk', ctx)
535
533
 
536
- // Option 2: Template path (rendered with Nunjucks)
537
- serverError: 'errors/500.njk',
534
+ // 500 Internal Server Error: Template path or custom handler fn
535
+ serverError: 'errors/500.njk', // or (err, req, res, ctx) => ...
538
536
 
539
- // Timeout error page (503)
540
- timeout: 'errors/503.njk'
537
+ // 503 Request Timeout: Template path or custom handler fn
538
+ timeout: 'errors/503.njk' // or (req, res, ctx) => ...
541
539
  }
542
540
  });
543
541
  ```
544
542
 
545
- Error templates receive these variables:
546
- - `404.njk`: `{ url, method }`
547
- - `500.njk`: `{ error, status, isDev }`
548
- - `503.njk`: `{ url, method, isDev }`
543
+ #### Error Template Context & Behavior
544
+
545
+ All error templates rendered with Nunjucks automatically receive a rich request context:
546
+ - **`404.njk` (`notFound`)**: `{ fsy, locale, isDev, url, method }` — sets HTTP status `404`. If omitted, Webspresso provides a responsive built-in HTML 404 page for browsers, or `{ error: 'Not Found', status: 404 }` JSON for API requests.
547
+ - **`500.njk` (`serverError`)**: `{ fsy, locale, isDev, url, method, error, status }` — sets HTTP status `500` (or `err.status`).
548
+ - **`503.njk` (`timeout`)**: `{ fsy, locale, isDev, url, method }` — sets HTTP status `503`.
549
+
550
+ ```njk
551
+ {# Example views/404.njk #}
552
+ {% extends "layout.njk" %}
553
+
554
+ {% block content %}
555
+ <div class="max-w-xl mx-auto text-center py-20">
556
+ <h1 class="text-6xl font-bold text-slate-800">404</h1>
557
+ <p class="text-xl text-slate-600 mt-4">Page Not Found: <code>{{ url }}</code></p>
558
+ <a href="{{ fsy.url('/') }}" class="mt-6 inline-block bg-blue-600 text-white px-6 py-2 rounded-lg">
559
+ Back to Home
560
+ </a>
561
+ </div>
562
+ {% endblock %}
563
+ ```
549
564
 
550
565
  **How errors reach this handler**
551
566
 
@@ -568,9 +583,11 @@ const { app } = createApp({
568
583
  });
569
584
  ```
570
585
 
571
- **Asset Management:**
586
+ **Asset Management & Cache-Busting:**
587
+
588
+ Webspresso includes a built-in `AssetManager` that automates static asset URL resolution, query-string cache-busting, Vite/Webpack build manifest lookups, and CDN prefixing in Nunjucks SSR templates.
572
589
 
573
- Configure asset handling with versioning and manifest support:
590
+ Configure asset handling in `createApp`:
574
591
 
575
592
  ```javascript
576
593
  const { createApp } = require('webspresso');
@@ -579,54 +596,84 @@ const path = require('path');
579
596
  const { app } = createApp({
580
597
  pagesDir: './pages',
581
598
  viewsDir: './views',
582
- publicDir: './public',
599
+ publicDir: './public', // default: 'public'
583
600
  assets: {
584
- // Option 1: Simple versioning (cache busting)
585
- version: '1.2.3', // or process.env.APP_VERSION
586
-
587
- // Option 2: Manifest file (Vite, Webpack, etc.)
601
+ // 1. Static query-string versioning (Cache Busting)
602
+ // Appends ?v=1.2.3 (or &v=... if existing query params) to asset URLs
603
+ version: process.env.APP_VERSION || '1.0.0', // or Git commit hash, build timestamp
604
+
605
+ // 2. Build Tool Manifest (Vite, Webpack, etc.)
606
+ // Resolves hashed bundle filenames automatically (e.g. style.css -> assets/style-abc123.css)
588
607
  manifestPath: path.join(__dirname, 'public/.vite/manifest.json'),
589
-
590
- // URL prefix for assets
591
- prefix: '/static'
608
+
609
+ // 3. Asset URL Prefix / CDN
610
+ // Prepended to all resolved asset paths
611
+ prefix: process.env.CDN_URL || '' // e.g. 'https://cdn.example.com' or '/static'
592
612
  }
593
613
  });
594
614
  ```
595
615
 
596
- Use asset helpers in templates:
616
+ #### How Asset Resolution Works
617
+
618
+ 1. **Manifest Match First**: If `manifestPath` is configured and an entry matches the asset key, `AssetManager` resolves to the hashed filename (from Vite or Webpack manifest) and prepends `prefix`.
619
+ 2. **Fallback to Path & Versioning**: If no manifest is provided or the asset is not listed in the manifest, `AssetManager` uses the original path, prepends `prefix`, and appends `?v=<version>` (or `&v=<version>`) if `version` is set.
620
+ 3. **Leading Slash Normalization**: Paths with or without leading slashes (e.g., `'css/style.css'` or `'/css/style.css'`) are handled seamlessly.
621
+
622
+ ```json
623
+ // Example Vite manifest format (.vite/manifest.json)
624
+ {
625
+ "css/style.css": { "file": "assets/style-abc123.css", "src": "css/style.css" },
626
+ "js/app.js": { "file": "assets/app-xyz789.js", "src": "js/app.js" }
627
+ }
628
+
629
+ // Example Webpack manifest format (manifest.json)
630
+ {
631
+ "/css/style.css": "/dist/style.abc123.css",
632
+ "/js/app.js": "/dist/app.xyz789.js"
633
+ }
634
+ ```
635
+
636
+ #### Template Helpers (`fsy`)
637
+
638
+ Use `fsy` helpers inside your Nunjucks layouts and pages:
597
639
 
598
640
  ```njk
599
- {# Using fsy helpers (auto-resolved) #}
641
+ {# 1. URL string resolution #}
600
642
  <link rel="stylesheet" href="{{ fsy.asset('/css/style.css') }}">
643
+ {# Outputs: <link rel="stylesheet" href="/css/style.css?v=1.0.0"> (or /assets/style-abc123.css) #}
601
644
 
602
- {# Or generate full HTML tags #}
645
+ {# 2. CSS link tag helper #}
603
646
  {{ fsy.css('/css/style.css') | safe }}
604
- {{ fsy.js('/js/app.js', { defer: true, type: 'module' }) | safe }}
605
- {{ fsy.img('/images/logo.png', 'Site Logo', { class: 'logo', loading: 'lazy' }) | safe }}
647
+ {# With custom attributes (media, id, integrity, etc.): #}
648
+ {{ fsy.css('/css/print.css', { media: 'print', id: 'print-styles' }) | safe }}
649
+
650
+ {# 3. JS script tag helper #}
651
+ {{ fsy.js('/js/app.js') | safe }}
652
+ {# Supports boolean flags (async, defer) and module type: #}
653
+ {{ fsy.js('/js/main.js', { defer: true, type: 'module' }) | safe }}
654
+ {# Outputs: <script src="/js/main.js?v=1.0.0" defer type="module"></script> #}
655
+
656
+ {# 4. Image tag helper #}
657
+ {{ fsy.img('/images/logo.png', 'Company Logo') | safe }}
658
+ {# With attributes (loading, class, width, height): #}
659
+ {{ fsy.img('/images/hero.jpg', 'Hero Banner', { loading: 'lazy', class: 'w-full rounded', width: 1200, height: 600 }) | safe }}
606
660
  ```
607
661
 
608
- Asset helpers available in `fsy`:
609
- - `asset(path)` - Returns versioned/manifest-resolved URL
610
- - `css(href, attrs)` - Generates `<link>` tag
611
- - `js(src, attrs)` - Generates `<script>` tag
612
- - `img(src, alt, attrs)` - Generates `<img>` tag
662
+ #### Programmatic Asset Manager API
613
663
 
614
- **Manifest Support:**
664
+ You can also import and use `AssetManager` directly:
615
665
 
616
- Works with Vite and Webpack manifest formats:
666
+ ```javascript
667
+ const { AssetManager, configureAssets, getAssetManager } = require('webspresso');
617
668
 
618
- ```json
619
- // Vite manifest format (.vite/manifest.json)
620
- {
621
- "css/style.css": { "file": "assets/style-abc123.css" },
622
- "js/app.js": { "file": "assets/app-xyz789.js" }
623
- }
669
+ // Configure global manager
670
+ configureAssets({
671
+ version: '2.0.0',
672
+ prefix: 'https://cdn.example.com'
673
+ });
624
674
 
625
- // Webpack manifest format
626
- {
627
- "/css/style.css": "/dist/style.abc123.css",
628
- "/js/app.js": "/dist/app.xyz789.js"
629
- }
675
+ const manager = getAssetManager();
676
+ console.log(manager.asset('/css/style.css')); // -> 'https://cdn.example.com/css/style.css?v=2.0.0'
630
677
  ```
631
678
 
632
679
  **Returns:** `{ app, nunjucksEnv, pluginManager, authMiddleware }` — `authMiddleware` is `null` when `auth` was not passed.
@@ -38,7 +38,7 @@ const DEFAULT_CONFIG = {
38
38
  saveUninitialized: false,
39
39
  cookie: {
40
40
  httpOnly: true,
41
- secure: process.env.NODE_ENV === 'production',
41
+ secure: process.env.NODE_ENV === 'production' ? true : 'auto',
42
42
  sameSite: 'lax',
43
43
  maxAge: 24 * 60 * 60 * 1000, // 1 day
44
44
  },
@@ -272,9 +272,11 @@ class AuthManager {
272
272
  throw new AuthenticationError('Invalid user object', 'INVALID_USER');
273
273
  }
274
274
 
275
- // Set session
276
- req.session.userId = user.id;
277
- req.session.loggedInAt = Date.now();
275
+ // Set session if active
276
+ if (req.session) {
277
+ req.session.userId = user.id;
278
+ req.session.loggedInAt = Date.now();
279
+ }
278
280
 
279
281
  // Set req.user
280
282
  req.user = user;
@@ -284,19 +286,21 @@ class AuthManager {
284
286
  await self.createRememberToken(user.id, res);
285
287
  }
286
288
 
287
- // Regenerate session ID for security
288
- return new Promise((resolve, reject) => {
289
- req.session.regenerate((err) => {
290
- if (err) {
291
- reject(new AuthenticationError('Session regeneration failed', 'SESSION_ERROR'));
292
- } else {
293
- // Restore userId after regenerate
294
- req.session.userId = user.id;
295
- req.session.loggedInAt = Date.now();
296
- resolve();
297
- }
289
+ // Regenerate session ID for security if supported
290
+ if (req.session && typeof req.session.regenerate === 'function') {
291
+ return new Promise((resolve, reject) => {
292
+ req.session.regenerate((err) => {
293
+ if (err) {
294
+ reject(new AuthenticationError('Session regeneration failed', 'SESSION_ERROR'));
295
+ } else {
296
+ // Restore userId after regenerate
297
+ req.session.userId = user.id;
298
+ req.session.loggedInAt = Date.now();
299
+ resolve();
300
+ }
301
+ });
298
302
  });
299
- });
303
+ }
300
304
  },
301
305
 
302
306
  /**
@@ -320,21 +324,25 @@ class AuthManager {
320
324
  }
321
325
 
322
326
  // Clear cookie
323
- res.clearCookie(self.config.rememberMe.cookieName);
327
+ if (typeof res?.clearCookie === 'function') {
328
+ res.clearCookie(self.config.rememberMe.cookieName);
329
+ }
324
330
  }
325
331
 
326
332
  // Clear user
327
333
  req.user = null;
328
334
 
329
- // Destroy session
330
- return new Promise((resolve) => {
331
- req.session.destroy((err) => {
332
- if (err) {
333
- console.error('Session destroy error:', err);
334
- }
335
- resolve();
335
+ // Destroy session if supported
336
+ if (req.session && typeof req.session.destroy === 'function') {
337
+ return new Promise((resolve) => {
338
+ req.session.destroy((err) => {
339
+ if (err) {
340
+ console.error('Session destroy error:', err);
341
+ }
342
+ resolve();
343
+ });
336
344
  });
337
- });
345
+ }
338
346
  },
339
347
 
340
348
  /**
@@ -715,7 +723,9 @@ class AuthManager {
715
723
  const payload = {
716
724
  id: user.id,
717
725
  type: 'access',
718
- [this.identifierField]: user[this.identifierField],
726
+ ...(this.identifierField && user[this.identifierField] ? { [this.identifierField]: user[this.identifierField] } : {}),
727
+ ...(user.email && { email: user.email }),
728
+ ...(user.role && { role: user.role }),
719
729
  ...options.payload,
720
730
  };
721
731
  return this.createJwt(payload, options);
@@ -15,11 +15,21 @@ const cookieParser = require('cookie-parser');
15
15
  function createAuthMiddleware(authManager) {
16
16
  const config = authManager.config;
17
17
 
18
+ const sessionConfig = authManager.getSessionConfig();
19
+
18
20
  /**
19
21
  * Session middleware
20
- * Sets up express-session
22
+ * Sets up express-session with secure cookie enforcement
21
23
  */
22
- const sessionMiddleware = session(authManager.getSessionConfig());
24
+ const sessionMiddleware = session({
25
+ ...sessionConfig,
26
+ cookie: {
27
+ httpOnly: true,
28
+ sameSite: 'lax',
29
+ secure: 'auto',
30
+ ...(sessionConfig.cookie || {}),
31
+ },
32
+ });
23
33
 
24
34
  /**
25
35
  * Cookie parser middleware (needed for signed cookies / remember me)
package/core/orm/model.js CHANGED
@@ -54,6 +54,7 @@ function defineModel(options) {
54
54
  const columns = extractColumnsFromSchema(schema);
55
55
 
56
56
  // Validate relations
57
+ const normalizedRelations = {};
57
58
  for (const [relationName, relation] of Object.entries(relations)) {
58
59
  if (!['belongsTo', 'hasMany', 'hasOne'].includes(relation.type)) {
59
60
  throw new Error(
@@ -70,6 +71,8 @@ function defineModel(options) {
70
71
  `Relation "${relationName}" in model "${name}" must have a foreignKey string`
71
72
  );
72
73
  }
74
+
75
+ normalizedRelations[relationName] = relation;
73
76
  }
74
77
 
75
78
  // Create model definition
@@ -78,7 +81,7 @@ function defineModel(options) {
78
81
  table,
79
82
  schema,
80
83
  primaryKey,
81
- relations,
84
+ relations: normalizedRelations,
82
85
  scopes: {
83
86
  softDelete: scopes.softDelete || false,
84
87
  timestamps: scopes.timestamps || false,
@@ -186,7 +189,10 @@ function unregisterModel(name) {
186
189
  * @returns {import('./types').ModelDefinition}
187
190
  */
188
191
  function resolveRelationModel(relation) {
189
- const model = relation.model();
192
+ let model = typeof relation.model === 'function' ? relation.model() : relation.model;
193
+ if (typeof model === 'string') {
194
+ model = getModel(model);
195
+ }
190
196
  if (!model || !model.name) {
191
197
  throw new Error('Invalid relation model reference');
192
198
  }
@@ -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.89",
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 || {};