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 +92 -45
- package/core/auth/manager.js +36 -26
- package/core/auth/middleware.js +12 -2
- package/core/orm/model.js +8 -2
- package/core/orm/scopes.js +2 -1
- package/package.json +1 -1
- package/plugins/admin-panel/app.js +10 -0
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
- package/plugins/admin-panel/client/parts/08-record-list.js +10 -10
- package/plugins/admin-panel/index.js +41 -1
- package/plugins/admin-panel/modules/admin-users.js +770 -0
- package/plugins/content/client/inline-edit.js +41 -17
- package/plugins/cors.js +12 -8
- package/plugins/csrf/index.js +8 -8
- package/src/file-router.js +31 -14
- package/src/helpers.js +4 -3
- package/src/plugin-manager.js +10 -3
- package/src/server.js +184 -18
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
package/README.md
CHANGED
|
@@ -528,24 +528,39 @@ const { app } = createApp({
|
|
|
528
528
|
pagesDir: './pages',
|
|
529
529
|
viewsDir: './views',
|
|
530
530
|
errorPages: {
|
|
531
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
-
|
|
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
|
|
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
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
{#
|
|
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
|
-
{#
|
|
645
|
+
{# 2. CSS link tag helper #}
|
|
603
646
|
{{ fsy.css('/css/style.css') | safe }}
|
|
604
|
-
{
|
|
605
|
-
{{ fsy.
|
|
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
|
-
|
|
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
|
-
|
|
664
|
+
You can also import and use `AssetManager` directly:
|
|
615
665
|
|
|
616
|
-
|
|
666
|
+
```javascript
|
|
667
|
+
const { AssetManager, configureAssets, getAssetManager } = require('webspresso');
|
|
617
668
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
}
|
|
669
|
+
// Configure global manager
|
|
670
|
+
configureAssets({
|
|
671
|
+
version: '2.0.0',
|
|
672
|
+
prefix: 'https://cdn.example.com'
|
|
673
|
+
});
|
|
624
674
|
|
|
625
|
-
|
|
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.
|
package/core/auth/manager.js
CHANGED
|
@@ -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
|
|
277
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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);
|
package/core/auth/middleware.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
}
|
package/core/orm/scopes.js
CHANGED
|
@@ -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
|
@@ -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
|
|
20
|
-
m('.text-sm.text-gray-
|
|
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-
|
|
22
|
+
m('span.font-semibold.text-gray-900.dark:text-slate-200', ((page - 1) * perPage) + 1),
|
|
23
23
|
' to ',
|
|
24
|
-
m('span.font-
|
|
24
|
+
m('span.font-semibold.text-gray-900.dark:text-slate-200', Math.min(page * perPage, total)),
|
|
25
25
|
' of ',
|
|
26
|
-
m('span.font-
|
|
26
|
+
m('span.font-semibold.text-gray-900.dark:text-slate-200', total),
|
|
27
27
|
' results',
|
|
28
28
|
]),
|
|
29
|
-
m('nav.flex.items-center.
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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-
|
|
56
|
-
m('button.px-3.py-1.rounded.text-sm.text-gray-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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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:
|
|
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 || {};
|